{"repo": "aliatx2017/reasonix-hermes", "files": {"reasonix-deepseek-ecosystem-2026.md": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785595217.3285542, "n": 53168, "h": "71745231fba9c0b7", "c": "# Reasonix + DeepSeek Ecosystem: Complete Reference (2026)\n\n> A comprehensive survey of integrations, plugins, tools, skills, MCP bridges, protocols, and use cases in the Reasonix ecosystem as of mid-2026.\n\n---\n\n## Table of Contents\n\n1. [Overview: What is Reasonix?](#1-overview-what-is-reasonix)\n2. [Core Architecture & Cost Model](#2-core-architecture--cost-model)\n3. [Official Integration (DeepSeek Docs)](#3-official-integration-deepseek-docs)\n4. [Desktop Application v1.6.0](#4-desktop-application-v150)\n - 4.1 [Key Desktop Features](#key-desktop-features)\n - 4.2 [Bot Gateway (Feishu / Weixin / QQ)](#bot-gateway-v150)\n - 4.3 [Desktop vs CLI](#desktop-vs-cli)\n5. [MCP Bridges & Cross-Platform Integrations](#5-mcp-bridges--cross-platform-integrations)\n - 5.1 [reasonix-mcp-server (Claude Code ↔ Reasonix)](#51-reasonix-mcp-server-claude-code--reasonix)\n - 5.2 [reasonix-bridge (Codex ↔ DeepSeek, Multi-Agent)](#52-reasonix-bridge-codex--deepseek-multi-agent)\n - 5.3 [opencode-reasonix-mcp (OpenCode Bridge)](#53-opencode-reasonix-mcp-opencode-bridge)\n - 5.4 [collab-cli (Universal Multi-Agent Protocol)](#54-collab-cli-universal-multi-agent-protocol)\n6. [Community Skills Hub (awesome-reasonix)](#6-community-skills-hub-awesome-reasonix)\n - 6.1 [Full Skill Registry](#61-full-skill-registry)\n - 6.2 [Skill Categories](#62-skill-categories)\n7. [Long-Term Memory: Hindsight-Reasonix](#7-long-term-memory-hindsight-reasonix)\n8. [Portable Toolkit: Reasonix PortaKit](#8-portable-toolkit-reasonix-portakit)\n9. [Protocol Landscape: MCP / ACP / CDP](#9-protocol-landscape-mcp--acp--cdp)\n10. [Use Cases & Workflows](#10-use-cases--workflows)\n11. [Appendix: Quick-Reference Links](#11-appendix-quick-reference-links)\n\n---\n\n## 1. Overview: What is Reasonix?\n\n**Reasonix** is an open-source (MIT), terminal-based AI coding agent built specifically for **DeepSeek models**. It is listed on DeepSeek's official API documentation as a recommended integration, giving it a degree of first-party endorsement that few third-party tools have.\n\n| Attribute | Value |\n|-----------|-------|\n| **License** | MIT (fully open source) |\n| **Language** | Go (1.0, `main-v2` branch — current); TypeScript (0.x, `v1` branch — legacy/maintenance) |\n| **GitHub Stars** | 20,700+ |\n| **Forks** | 1,200+ |\n| **Releases** | 50+ (v1.10.0 — Jun 2026) |\n| **Commits** | 777+ |\n| **Default Model** | DeepSeek V4 Flash (via config TOML); also presets for DeepSeek V4 Pro, MiMo-v2.5-pro, MiMo-v2.5 |\n| **Upgrade Model** | DeepSeek V4 Pro or MiMo-v2.5-pro (per-model via `/model` or `/preset max`) |\n| **Platforms** | macOS (brew + prebuilt), Linux (.deb + prebuilt), Windows (prebuilt, code-signed via SignPath) |\n| **Config Format** | TOML (`reasonix.toml` — flag > project > user > defaults resolution) |\n| **Node Requirement** | None for v1.0 Go binary; Node ≥ 22 only for npm install shim or legacy 0.x |\n| **Alias** | `dsnix` |\n| **Install** | `npm i -g reasonix`, `brew install esengine/reasonix/reasonix`, prebuilt archives (6 targets), `make build`/`make cross` from source |\n| **Official Docs** | [DeepSeek Agent Integrations](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix); [Guide (GUIDE.md)](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md); [Spec (SPEC.md)](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SPEC.md) |\n\n### Three Operating Modes\n\n| Mode | Command | Description |\n|------|---------|-------------|\n| **chat** (default) | `reasonix chat` | Interactive conversation with full tool access. Run `/init` to generate AGENTS.md project memory. Use for actual development. |\n| **run** (one-shot) | `reasonix run \"task\"` | Executes a single task and exits. Accepts piped input and `--model` flag. Ideal for CI/CD, scripting, automation pipelines. |\n| **code** | `reasonix chat` | Legacy alias preserved from 0.x; routes to `chat` in v1.0+. |\n\n### Model Switching\n\n- **`/model`** — Interactively switch the active model mid-session; selection persists to user config.\n- **`/preset max`** — Switches the entire session to a higher-tier model (e.g. DeepSeek V4 Pro).\n- **Two-model collaboration** — Set `[agent].planner_model` in `reasonix.toml` to run a separate low-frequency planner (e.g. MiMo-v2.5-pro) alongside the executor, each in its own cache-stable session.\n- **Subagent models** — `subagent_model` and `subagent_models` config keys let specific skills (like `review`, `security_review`) run on different models.\n- **Multi-provider presets** — DeepSeek (flash/pro) and MiMo (mimo-v2.5-pro / mimo-v2.5) ship as config presets; any OpenAI-compatible endpoint is a config entry.\n\n### Built-in Features\n\n- **Plan mode** — Auto-plan (`/auto-plan on`) or manual (`/plan`); two-model planner with read-only research tools before executor acts.\n- **Checkpoints & Rewind** — File-snapshot-based undo (`Esc-Esc` or `/rewind`); restore code, conversation, or both; fork-from-here; works without touching `.git`.\n- **Goal mode** — `/goal ` starts an autonomous, session-scoped active goal loop with blocked-state audit; desktop-only.\n- **Memory** — Hierarchical `REASONIX.md` (committed) + `REASONIX.local.md` (personal, gitignored) + user-global `~/.config/reasonix/REASONIX.md` + ancestor dirs; `AGENTS.md` accepted as fallback.\n- **`remember` tool** — Saves durable facts to per-project auto-memory (frontmatter files + `MEMORY.md` index), loaded into prefix on next session.\n- **`#` quick-add** — Type `#` in chat to quick-add a line to the project's `REASONIX.md`.\n- **`@path` imports** — A line containing `@path/to/another/file.md` injects that file's content into the memory prefix.\n- **Session branching** — `/tree` shows saved conversation branches; `/branch [name]` forks current tip; `/branch [name]` forks from a checkpointed turn; `/switch ` loads another branch.\n- **Subagent transcripts** — `/task` spawns sub-agents; continue/resume past transcripts in v1.6.0+.\n- **MCP/Plugins** — First-class MCP client: stdio, HTTP (Streamable), and SSE transports; `.mcp.json` drop-in from Claude Code works unchanged.\n- **Skills** — Reusable prompt templates in `.reasonix/skills/*.md`; frontmatter supports `runAs: subagent` and `allowed-tools` for isolated execution; `/skill new my-skill` scaffolds one.\n- **`read_skill` tool** — Load inline skills in plan mode without execution (v1.6.0+).\n- **Slash commands** — 20+ built-ins (`/compact`, `/new`, `/clear`, `/rewind`, `/tree`, `/branch`, `/switch`, `/todo`, `/model`, `/mcp`, `/skills`, `/hooks`, `/memory`, `/output-style`, `/sandbox`, `/language`, `/auto-plan`, `/help`); custom commands in `.reasonix/commands/*.md`.\n- **Chat references (`@`)** — `@path/to/file` injects file contents (or directory listing) as tagged context; `@:` injects MCP resources. Autocomplete on `/` or `@`.\n- **Web search** — Mojeek, SearXNG, or Metaso backends.\n- **Transcript replay & events** — Every event hits disk; `reasonix replay`, `reasonix events`, `reasonix stats`.\n- **Bot gateway** — Feishu/Weixin/QQ adapters (v1.6.0+).\n- **PDF extraction** — Extract text from PDF attachments (v1.6.0+).\n- **Sandbox** — macOS Seatbelt enforcement for Bash (jailed to workspace + temp + toolchain caches, network opt-in); file-writers confined to `workspace_root` with symlink/`..` traversal prevention; Linux bubblewrap/landlock on roadmap.\n- **Permisssions** — `deny > ask > allow > fallback` per-tool-call gating; tool-approval postures: ask, auto, yolo.\n- **Context compaction** — Low-frequency cache-reset point when approaching `context_window` limit; older middle of session summarized; dropped originals archived as JSONL.\n- **Codegraph** — Semantic code index for faster file discovery (daemon process with MCP interface).\n\n---\n\n## 2. Core Architecture & Cost Model\n\n### Cache-First Philosophy\n\nReasonix is engineered around **DeepSeek's byte-stable prefix cache**. Every LLM API provider offers prefix caching, but DeepSeek's is particularly aggressive — if the start of a prompt matches a cached prefix exactly, the input token cost drops to ~1/5th the normal rate (per official reasonix.homes: ~94% cache hit, ~2.5× cost reduction). Any change invalidates the cache.\n\nGeneric tools restructure prompts for correctness. Reasonix structures them for **cache stability**:\n\n1. **Fixed prefix ordering** — System prompt, project context, and memory are always in the same position. New information is appended, never inserted.\n2. **Incremental context growth** — Files added to context go at the end. Nothing before them shifts.\n3. **Stable tool call formatting** — Tool results are formatted identically every time.\n\n### Real-World Cost Numbers\n\n| Session Type | Input Tokens | Cache Hit Rate | Effective Cost | Without Cache |\n|:-------------|:-------------|:---------------|:---------------|:--------------|\n| Quick fix (5 min) | 50K | 99.5% | ~$0.01 | ~$0.04 |\n| Feature (30 min) | 500K | 99.8% | ~$0.05 | ~$0.24 |\n| Refactor (2 hrs) | 3M | 99.9% | ~$0.15 | ~$1.31 |\n| Full day coding | 20M | 99.8% | ~$0.85 | ~$4.12 |\n\n**Headline stat**: 435M input tokens processed with 99.82% cache hits, costing approximately **$12** instead of the **$61** it would have cost without cache optimization. (Real production data, not a benchmark.)\n\n### Configuration\n\n**Resolution order**: `flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults`.\n\nSecrets come from the environment via `api_key_env` and are **never stored in config files**. A `.env` in the working directory is loaded if present. Step-limit preferences belong in user config; project `reasonix.toml` should override them only when the repository needs shared runtime bounds.\n\n**Minimal `reasonix.toml`** — one provider and a default model is enough to start:\n\n```toml\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n```\n\n**Full example**:\n\n```toml\ndefault_model = \"deepseek-flash\" # executor; set [agent].planner_model to add a planner\n\n[agent]\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\n# planner_model = \"mimo-pro\" # optional low-frequency planner\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" }\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n\n[tools]\nenabled = [] # omit/empty = all built-ins\nbash_timeout_seconds = 120\n\n[skills]\npaths = [] # extra custom skill roots\nexcluded_paths = [] # hide convention roots without deleting\ndisabled_skills = [] # hidden from prompt, slash invocation, and skill tools\n\n[permissions]\nmode = \"ask\" # writer fallback: ask|allow|deny\ndeny = [\"Bash(rm -rf*)\", \"Bash(git push*)\"] # hard-blocked in every mode\nallow = [\"Bash(go test:*)\"] # never prompted\n\n[sandbox]\n# workspace_root = \"\" # file-writers confined here; empty = current dir\n# allow_write = [\"/tmp\"]\n\n[[plugins]]\nname = \"github\"\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-github\"]\n\n# Remote MCP server over Streamable HTTP\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n```\n\n**`.mcp.json` drop-in support**: Reasonix also reads a project-root `.mcp.json` using Claude Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`, `${VAR}` expansion). Both sources are merged; on a name collision `reasonix.toml` wins.\n\n```json\n{\n \"mcpServers\": {\n \"filesystem\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/path\"] },\n \"stripe\": { \"type\": \"http\", \"url\": \"https://mcp.stripe.com\", \"headers\": { \"Authorization\": \"Bearer ${STRIPE_KEY}\" } }\n }\n}\n```\n\n---\n\n## 3. Official Integration (DeepSeek Docs)\n\nDeepSeek's official API documentation includes Reasonix as a **recommended agent integration** alongside Claude Code, GitHub Copilot, WorkBuddy, OpenCode, Kilo Code, and others.\n\n**Installation**:\n\n```bash\n# Any OS — pulls the prebuilt Go native binary\nnpm i -g reasonix\n\n# macOS\nbrew install esengine/reasonix/reasonix\n\n# Prebuilt archives for all platforms (darwin|linux|windows × amd64|arm64)\n# Available on every GitHub release, with SHA256SUMS\n\n# Build from source\ngit clone https://github.com/esengine/deepseek-reasonix.git\ncd deepseek-reasonix\nmake build # -> bin/reasonix(.exe)\nmake cross # -> dist/ (6 targets)\n```\n\nWindows builds are **code-signed** with a free certificate provided by the [SignPath Foundation](https://signpath.io/). Linux `.deb` packages are built on release.\n\n**Quick-start** (from [deepseek.com](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix)):\n\n```bash\n# 1. Get a DeepSeek API Key from platform.deepseek.com\n# 2. Run the config wizard:\nreasonix setup # writes ./reasonix.toml\n\n# 3. Start chatting:\nexport DEEPSEEK_API_KEY=sk-... # or put it in .env\nreasonix chat # then run /init to generate AGENTS.md\n\n# Or one-shot:\nreasonix run \"implement the TODOs in main.go\"\nreasonix run --model mimo-pro \"add unit tests for this function\"\necho \"explain this code\" | reasonix run\n```\n\nNo Node.js required for v1.0+ (single Go binary). First launch with `reasonix setup` writes a minimal `reasonix.toml`. Secrets stay in environment variables (`api_key_env`), never in config files.\n\n---\n\n## 4. Desktop Application v1.10.0\n\n**Built into the main repo**: [desktop/](https://github.com/esengine/deepseek-reasonix/tree/main-v2/desktop) directory in `esengine/DeepSeek-Reasonix`. \n**Stack**: Wails + React 19 + TypeScript 6 | **Latest**: v1.10.0 (Jun 2026)\n\nThe desktop app is a **visual companion to the CLI**, not a Cursor replacement. It provides a GUI wrapper around the same Go controller — the terminal TUI, desktop webview, and HTTP/SSE server all drive the same `control.Controller`, so every feature surfaces identically across frontends.\n\n### Key Desktop Features\n\n- **Themed workspace UI** — Dark and light themes with a full sidebar: session list, file tree, MCP settings panel, skills browser, and memory view. Screenshots at `docs/desktop-theme-dark.jpg` and `docs/desktop-theme-light.jpg`.\n- **Collaboration-mode picker** — Three choices: **normal** (standard chat), **plan** (`/plan`), and **goal** (`/goal`).\n- **Goal mode** (`/goal `) — Starts an autonomous, session-scoped active goal loop. The controller prepends goal context to user turns (outside the cache-stable system prompt) and keeps issuing continuation turns until the model reports completion, repeats the same blocked state three times, or the safety limit is reached. Blocked-state matching is normalized for casing, whitespace, and punctuation. `/goal clear` removes the active goal.\n- **Tool-approval postures** — **ask** (each writer/bash call prompts), **auto** (writer fallback auto-allowed, explicit ask/deny rules still apply), **yolo** (approval prompts auto-allowed unless denied). These are separate from the collaboration-mode picker.\n- **`read_skill` tool** — Load inline skills in plan mode without execution (v1.6.0+).\n- **Session workspace isolation** — Desktop sessions are isolated per workspace directory; switching workspaces loads the correct session history.\n- **MCP settings panel** — View connected servers, connection status, retry failures, inspect each server's tools/prompts/resources. Collapses noisy startup errors into a summary.\n- **Reveal-in-file-manager** — Right-click any file in the workspace tree to reveal it in Finder/Explorer.\n- **Image paste** — Paste clipboard images directly into the chat composer.\n- **Model selector dropdown** — Switch models interactively; scroll support and tooltips for truncated names; preserves curated provider models on refresh.\n- **Rewind (hover)** — Each user message in the transcript has a hover rewind control → menu: rewind code / rewind conversation / both / fork-from-here.\n- **Subagent transcript continue/resume** — Continue past subagent transcripts from where they left off (v1.6.0+).\n- **PDF attachment text extraction** — Extract text from PDF attachments dropped into the chat (v1.6.0+).\n\n### Bot Gateway (v1.6.0+)\n\nReasonix ships a **bot gateway** with adapters for:\n- **Feishu (飞书)** — Chinese enterprise messaging\n- **Weixin (微信)** — China's dominant messaging app\n- **QQ** — Tencent's instant messaging platform\n\nThe gateway receives messages from these platforms, routes them through the Reasonix agent, and sends responses back. Each adapter handles authentication (Feishu webhook tokens, Weixin account/login). This turns Reasonix into a deployable AI coding assistant accessible from chat platforms — useful for teams that want a shared coding agent reachable from their existing messaging tools.\n\n### Desktop vs CLI\n\n| Feature | CLI (TUI) | Desktop |\n|---------|-----------|---------|\n| Chat | Full transcript, scrolling (Ctrl+Home/End in v1.6.0) | Visual transcript with hover controls |\n| Plan mode | `/plan` | Collaboration-mode picker |\n| Goal mode | Not available | `/goal` with blocked-state audit |\n| Checkpoints/Rewind | `Esc-Esc` / `/rewind` picker | Hover rewind on each message |\n| MCP management | `/mcp` slash command | Full settings panel with retry |\n| Skills | `/skills` | Visual browser |\n| File tree | `/tree` | Sidebar with reveal-in-file-manager |\n| Image paste | Via file path | Clipboard paste |\n| Bot gateway | Available via serve mode | Included |\n\n---\n\n## 5. MCP Bridges & Cross-Platform Integrations\n\n### 5.1 reasonix-mcp-server (Claude Code ↔ Reasonix)\n\n**Repo**: [github.com/Belveth02/reasonix-mcp-server](https://github.com/Belveth02/reasonix-mcp-server) \n**Stars**: ⭐2 | **Language**: JavaScript | **License**: MIT\n\nThis MCP server lets **Claude Code call Reasonix as a DeepSeek sub-agent** through the Model Context Protocol. The core idea: use Claude for high-level reasoning and orchestration, but delegate the actual file-editing work to Reasonix (which costs ~1/5 as much thanks to prefix caching).\n\n#### Tools Exposed\n\n| Tool | Parameters | Description |\n|------|------------|-------------|\n| `reasonix_run` | `task` (string, required), `model` (string, optional), `effort` (enum: low/medium/high/max), `budget` (number, optional), `timeout` (number, default 300000ms) | Execute a coding task via Reasonix |\n| `reasonix_doctor` | `json` (boolean, optional) | Run health check: API key, network, config |\n\n#### Installation\n\n```bash\nnpm install -g reasonix\ngit clone https://github.com/Belveth/reasonix-mcp-server.git\ncd reasonix-mcp-server\nnpm install\n```\n\nRegister with Claude Code:\n\n```bash\nclaude mcp add reasonix --scope user -- \\\n --command node \\\n --args \"/absolute/path/to/server.js\"\n```\n\nOr manually edit `~/.claude.json`:\n\n```json\n\"mcpServers\": {\n \"reasonix\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\"C:\\\\Users\\\\xxx\\\\.claude\\\\tools\\\\reasonix-mcp-server\\\\server.js\"],\n \"env\": {}\n }\n}\n```\n\n#### Cost Comparison\n\nIn real-world testing: 435M tokens processed at ~$12 via Reasonix/DeepSeek. Equivalent work on Claude: ~$61. That's an **~80% cost reduction** by routing the heavy file-editing work through Reasonix's cache-optimized DeepSeek pipeline.\n\n---\n\n### 5.2 reasonix-bridge (Codex ↔ DeepSeek, Multi-Agent)\n\n**Repo**: [github.com/OwlCodeTech/reasonix-bridge](https://github.com/OwlCodeTech/reasonix-bridge) \n**Stars**: ⭐6 | **Language**: JavaScript (~1500 lines, single file) | **License**: MIT\n\nA **multi-agent parallel execution engine** that lets **Codex delegate tasks to DeepSeek via MCP**. Supports two-phase execution, multi-agent parallelism, incremental mode, and dynamic budget allocation.\n\n#### Architecture\n\n```\nCodex (MCP Client)\n │ stdio pipe\n ▼\nindex.js (MCP Server)\n │\n ├─ 6 MCP tools\n ├─ 8 local sandbox tools (read_file / write_file / run_command / ...)\n └─ DeepSeek API (streaming)\n ├─ Planning mode (no tools, produces plan)\n └─ Execution mode (8 tools, agent loop)\n```\n\n#### Tools Exposed\n\n| Tool | Purpose | Recommended Use |\n|------|---------|-----------------|\n| `execute_task` | Auto-detect: single or multi-agent | Daily use |\n| `plan_task` | Generate plan only, no execution | \"Show me the plan first\" |\n| `execute_step` | Execute a specific step | Pair with `plan_task` |\n| `orchestrate_task` | Force multi-agent parallelism | When explicit division of labor is needed |\n| `improve_file` | DeepSeek-targeted fix; Codex retains override | When Codex finds something to fix |\n| `delegate_to_reasonix` | Legacy one-shot execution | Backward compatibility |\n\n#### The \"Scientist Team\" (Role-Based Parallel Agents)\n\nWhen a task requires multi-agent execution, Bridge assigns roles automatically:\n\n| Emoji | Scientist | Role | Domain |\n|:-----|:----------|:-----|:--------|\n| 🖥️ | Dijkstra | Backend Engineer | Servers, APIs, algorithms |\n| 🎨 | Hopper | Frontend Developer | UI, interactions, components |\n| 🗄️ | Turing | Data Engineer | Database, storage |\n| 🔗 | Berners-Lee | Interface Engineer | API communication |\n| 🧪 | Lovelace | Test Engineer | Validation, deployment |\n\n**Sub-agent decomposition**: A single-domain complex task auto-splits:\n```\n🖥️ Dijkstra\n ├─ Dijkstra-A (user auth module)\n ├─ Dijkstra-B (CRUD operations)\n └─ Dijkstra-C (file upload)\n```\n\n#### Two-Phase Execution\n\n1. **Phase 1 (Planning)**: DeepSeek decomposes task → JSON step list (5-10 seconds)\n2. **Phase 2 (Execution)**: Step-by-step or multi-agent parallel (each step has independent context)\n - Previous outputs already on disk, unaffected by subsequent budget limits\n\n**Parallel scheduling**: Independent sub-tasks → `Promise.all` parallel execution. Dependent sub-tasks → wait for dependencies. Interface contracts extracted after each step and passed to subsequent agents.\n\n#### Security Model: 5-Level Command Control\n\n| Mode | Permissions |\n|------|-------------|\n| `off` | No commands, read-only files |\n| `readonly` | `pwd`, `echo`, `which` |\n| `static` | + `git` (read-only), `node --check`, `tsc` |\n| `verify` | + `npm test`, `eslint`, `jest` |\n| `full` | All open + write files |\n\nAdditional security: file sandbox (validates all paths, rejects traversal/symlink escapes), hash-based overwrite prevention, read-only mode auto-disables `edit_file`/`write_file`, forced-write audit logging, `REASONIX_MAX_TOKENS` hard cap.\n\n#### Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `DEEPSEEK_API_KEY` | — | Required |\n| `REASONIX_COMMAND_MODE` | `static` | Command permission level |\n| `WORKSPACE_ROOT` | `cwd` | Sandbox root directory |\n| `DEEPSEEK_MODEL` | `deepseek-chat` | Model identifier |\n| `DEEPSEEK_TEMPERATURE` | `0.2` | Temperature |\n| `REASONIX_MAX_TOKENS` | `64000` | Cumulative token budget |\n| `HTTP_PROXY` | — | Proxy (for non-China API access) |\n| `GITHUB_TOKEN` | — | GitHub API rate-limit boost |\n\n---\n\n### 5.3 opencode-reasonix-mcp (OpenCode Bridge)\n\n**Repo**: [github.com/StarWHAT-BUG/opencode-reasonix-mcp](https://github.com/StarWHAT-BUG/opencode-reasonix-mcp) \n**Stars**: ⭐0 | **Language**: JavaScript\n\nDispatches **stateless sub-tasks from OpenCode to Reasonix CLI** via MCP, saving ~99% of token costs compared to running the same tasks on Claude or GPT, by routing them through DeepSeek's prefix-cached pipeline.\n\n---\n\n### 5.4 collab-cli (Universal Multi-Agent Protocol)\n\n**Repo**: [github.com/Staryinsang0910-star/collab-cli](https://github.com/Staryinsang0910-star/collab-cli) \n**Stars**: ⭐7 | **Language**: TypeScript | **Tags**: `mcp`, `collaboration`, `protocol`, `multi-agent`\n\nA **universal collaboration protocol + CLI** for multi-agent LLM teams. Supports **Claude Code, Reasonix, Codex, WorkBuddy, and Cursor** as peers in a shared agent mesh. This is the closest project found to an **ACP (Agent Communication Protocol)** implementation — it defines how different agent systems discover each other, delegate work, share context, and synchronize results.\n\n---\n\n## 6. Community Skills Hub (awesome-reasonix)\n\n**Repo**: [github.com/hikari-2424/awesome-reasonix](https://github.com/hikari-2424/awesome-reasonix) \n**Stars**: ⭐2 | **Language**: HTML / Markdown | **License**: MIT \n**Site**: [hikari-2424.github.io/awesome-reasonix-site/](https://hikari-2424.github.io/awesome-reasonix-site/)\n\nAn **independent community project** (not affiliated with Reasonix) that collects, rates, and shares community-contributed Skills. Skills are Markdown files dropped into `.reasonix/skills/` and invoked via `/skill `.\n\n**Registry version**: 3 | **Last updated**: 2026-05-30 | **80+ published skills**\n\n### Installation\n\n```bash\n# Single skill\ncurl -o .reasonix/skills/file-organizer.md https://raw.githubusercontent.com/hikari-2424/awesome-reasonix/main/skills/file-organizer.md\n\n# Then inside Reasonix:\n> /skill file-organizer\n```\n\n### 6.1 Full Skill Registry\n\n#### File & System\n\n| Skill | Description |\n|-------|-------------|\n| `file-organizer` | Classify files by extension, find duplicates, tree view, clean empty dirs |\n| `disk-cleaner` | Disk space analysis and cleanup automation |\n\n#### Text Processing\n\n| Skill | Description |\n|-------|-------------|\n| `text-polish` | Polish, translate, summarize, rewrite — 4 output styles (professional/casual/concise/tech-doc) |\n| `data-convert` | Convert data between formats (JSON, CSV, YAML, etc.) |\n| `crosspost` | Cross-post content across platforms |\n\n#### Learning & Research\n\n| Skill | Description |\n|-------|-------------|\n| `learn-topic` | Search → fetch → organize → summarize (subagent mode) |\n| `deep-research` | Deep research agent with iterative retrieval |\n| `data-scraper-agent` | Web scraping agent |\n\n#### System Control\n\n| Skill | Description |\n|-------|-------------|\n| `input-control` | Windows mouse/keyboard automation via PowerShell |\n| `knowledge-ops` | Knowledge base management operations |\n\n#### Development Tools\n\n| Skill | Description |\n|-------|-------------|\n| `git-helper` | Conventional Commits, PR descriptions, history analysis, conflict resolution |\n| `code-review` | 4-dimension review (logic, security, performance, style) with file:line output |\n| `changelog-generator` | Auto-generate CHANGELOG.md from git history, semver inference |\n| `database-migrations` | Database migration patterns and automation |\n| `deployment-patterns` | Deployment strategies and CI/CD patterns |\n| `docker-patterns` | Docker and containerization patterns |\n| `github-ops` | GitHub operations automation |\n\n#### Project Generation\n\n| Skill | Description |\n|-------|-------------|\n| `project-scaffold` | Interactive scaffolding: Python, TypeScript, Go, Rust |\n| `readme-gen` | Auto-generate README.md from project structure analysis |\n| `api-connector-builder` | Build API connectors matching existing integration patterns |\n\n#### API & Integration\n\n| Skill | Description |\n|-------|-------------|\n| `api-design` | REST API design patterns: naming, status codes, pagination, filtering, error responses, versioning |\n| `jira-integration` | Jira issue tracking integration |\n\n#### Content & Design\n\n| Skill | Description |\n|-------|-------------|\n| `article-writing` | Write articles, guides, blog posts, tutorials, newsletters |\n| `brand-voice` | Extract writing style profiles from real content, build reusable brand voice config |\n| `code-tour` | Create step-by-step CodeTour files with real file:line anchors |\n| `dashboard-builder` | Build dashboards and data visualizations |\n| `frontend-slides` | Create HTML presentations from scratch or convert PowerPoint |\n| `content-engine` | Content generation and management engine |\n| `liquid-glass-design` | Modern UI design patterns |\n\n#### AI & Agent\n\n| Skill | Description |\n|-------|-------------|\n| `autonomous-loops` | Autonomous agent loop patterns: from simple pipelines to RFC-driven multi-agent workflows |\n| `blueprint` | Turn one-line objective into multi-phase, multi-agent engineering plan with dependency graph |\n| `council` | Multi-agent discussion and decision-making council |\n| `continuous-learning` | Auto-extract reusable patterns from Claude Code sessions |\n| `continuous-learning-v2` | Instinct-based learning system with confidence scoring |\n| `agentic-engineering` | Agent-driven engineering patterns |\n| `agent-harness-construction` | Build agent harnesses for testing and evaluation |\n| `agent-introspection-debugging` | Debug and introspect agent behavior |\n| `agent-sort` | Agent categorization and sorting |\n| `ai-first-engineering` | AI-first development methodology |\n| `eval-harness` | Formal evaluation framework for AI sessions |\n| `cost-aware-llm-pipeline` | Cost-optimized LLM pipeline patterns |\n| `iterative-retrieval` | Progressive context retrieval pattern |\n\n#### Architecture & Standards\n\n| Skill | Description |\n|-------|-------------|\n| `coding-standards` | Cross-project coding conventions: naming, readability, immutability, review criteria |\n| `backend-patterns` | Backend architecture: API design, database optimization, Node.js/Express/Next.js best practices |\n| `frontend-patterns` | Frontend patterns: React, Next.js, state management, performance |\n| `frontend-design` | UI/UX design patterns |\n| `android-clean-architecture` | Clean Architecture for Android/Kotlin Multiplatform |\n| `compose-multiplatform-patterns` | Compose Multiplatform / Jetpack Compose: state, navigation, theming, testing |\n| `dart-flutter-patterns` | Dart and Flutter patterns |\n| `kotlin-patterns` | Kotlin patterns and best practices |\n| `kotlin-coroutines-flows` | Coroutines and Flow patterns |\n| `kotlin-exposed-patterns` | JetBrains Exposed ORM patterns |\n| `kotlin-ktor-patterns` | Ktor framework patterns |\n| `java-coding-standards` | Java coding standards for Spring Boot |\n| `jpa-patterns` | JPA/Hibernate patterns |\n| `springboot-patterns` | Spring Boot architecture and patterns |\n| `django-patterns` | Django architecture patterns |\n| `django-security` | Django security best practices |\n| `laravel-patterns` | Laravel framework patterns |\n| `laravel-security` | Laravel security practices |\n| `golang-patterns` | Idiomatic Go patterns |\n| `cpp-coding-standards` | C++ Core Guidelines-based standards |\n| `dotnet-patterns` | .NET architecture patterns |\n| `perl-patterns` | Perl patterns and best practices |\n| `rust-patterns` | Rust patterns and best practices |\n| `clickhouse-io` | ClickHouse database patterns and optimization |\n\n#### Testing\n\n| Skill | Description |\n|-------|-------------|\n| `django-tdd` | Django testing with pytest-django, TDD methodology |\n| `django-verification` | Django verification loop: migrations, linting, tests, security |\n| `laravel-tdd` | Laravel testing patterns |\n| `laravel-verification` | Laravel verification loop |\n| `springboot-tdd` | Spring Boot testing patterns |\n| `springboot-verification` | Spring Boot verification loop |\n| `golang-testing` | Go testing: table-driven, subtests, benchmarks, fuzzing |\n| `cpp-testing` | C++ testing with GoogleTest/CTest |\n| `kotlin-testing` | Kotlin testing patterns |\n| `csharp-testing` | C# testing patterns |\n| `e2e-testing` | Playwright E2E testing, Page Object Model, CI/CD |\n| `ai-regression-testing` | Regression testing for AI-assisted development |\n\n#### Business & Operations\n\n| Skill | Description |\n|-------|-------------|\n| `carrier-relationship-management` | Carrier portfolio management, freight rate negotiation, performance tracking |\n| `customer-billing-ops` | Customer billing operations |\n| `customs-trade-compliance` | Customs and trade compliance |\n| `energy-procurement` | Energy procurement strategies |\n| `enterprise-agent-ops` | Enterprise agent operations |\n| `finance-billing-ops` | Finance and billing operations |\n| `inventory-demand-planning` | Inventory and demand planning |\n| `investor-materials` | Investor materials generation |\n| `investor-outreach` | Investor outreach automation |\n| `lead-intelligence` | Lead intelligence and scoring |\n| `logistics-exception-management` | Logistics exception handling |\n| `connections-optimizer` | Network/connections optimization |\n\n#### Security & Compliance\n\n| Skill | Description |\n|-------|-------------|\n| `defi-amm-security` | DeFi AMM security patterns |\n| `llm-trading-agent-security` | LLM trading agent security |\n| `evm-token-decimals` | EVM token decimal handling |\n| `healthcare-phi-compliance` | Healthcare PHI compliance |\n| `hipaa-compliance` | HIPAA compliance patterns |\n| `ecc-tools-cost-audit` | Tool cost auditing for ECC |\n\n### 6.2 Skill Categories\n\n| # | Category | Count |\n|---|----------|-------|\n| 1 | AI & Agent | ~15 |\n| 2 | Architecture & Standards | ~25 |\n| 3 | Testing | ~12 |\n| 4 | Backend & API | ~10 |\n| 5 | Frontend & UI | ~6 |\n| 6 | Business & Operations | ~12 |\n| 7 | Security & Compliance | ~6 |\n| 8 | Content & Design | ~7 |\n| 9 | File & System | ~3 |\n| 10 | Learning & Research | ~3 |\n| 11 | Project Generation | ~3 |\n| 12 | Text Processing | ~3 |\n\n### Rating & Safety System\n\nThe awesome-reasonix project includes:\n- **Validation pipeline**: Automated checks on PRs validate format and safety. Pass → published immediately. Fail → comment explains why.\n- **Rating system**: `ratings.json` tracks community ratings. Low-rated skills hidden automatically.\n- **Quarantine**: `quarantine.json` for flagged skills.\n- **Security policy**: `SECURITY.md` + `CODE_OF_CONDUCT.md`. Skills run on your machine — read the file, check ratings, test first.\n\n---\n\n## 7. Long-Term Memory: Hindsight-Reasonix\n\n**Repo**: [github.com/houycth/Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix) \n**Stars**: ⭐0 | **Language**: Python | **Dependencies**: `hindsight-client>=0.4.22`, `mcp>=1.0.0`\n\nA **cross-session long-term memory system** adapted for Reasonix's Hook and MCP mechanisms. Uses the Hindsight API backend for vector storage and retrieval.\n\n### Architecture\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│ hindsight_hooks.py ← auto-triggered by Reasonix hooks │\n│ ├─ session-start → health check │\n│ ├─ pre-turn → recall memory → stdout injection → LLM │\n│ └─ stop → read JSONL → retain conversation turn │\n├──────────────────────────────────────────────────────────────┤\n│ hindsight_mcp.py ← optional MCP server for active search │\n│ ├─ hindsight_recall(query) → search memory │\n│ ├─ hindsight_reflect(query) → synthesize │\n│ └─ hindsight_retain(...) → manual save │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### Data Flow\n\n```\nTurn N:\n UserPromptSubmit → pre-turn:\n ① recall(user message) → stdout → Reasonix captures & injects as \n ② LLM sees memory automatically (no manual reading required)\n\nStop → stop:\n ① Get session_id from hook payload\n ② Locate session JSONL → read last user message\n ③ Strip injection block\n ④ retain(original user message + assistant reply) → store in Hindsight\n```\n\n### Key Features\n\n- **Auto-prefetch**: Pre-turn queries memory and injects via stdout. LLM sees it 100% automatically.\n- **Auto-write**: Stop hook ensures every turn is stored (without injected `` content).\n- **Active search**: LLM can use MCP tools `hindsight_recall` for deep retrieval.\n\n### Setup\n\nThree Reasonix hooks must be configured in `~/.reasonix/settings.json`:\n\n```json\n{\n \"hooks\": {\n \"SessionStart\": [{ \"command\": \"python hindsight_hooks.py session-start\", \"timeout\": 15000 }],\n \"UserPromptSubmit\": [{ \"command\": \"python hindsight_hooks.py pre-turn\", \"timeout\": 30000 }],\n \"Stop\": [{ \"command\": \"python hindsight_hooks.py stop\", \"timeout\": 30000 }]\n }\n}\n```\n\n**Note**: Requires patched Reasonix source — specifically `internal/hook/hook.go`, `internal/hook/runner.go`, `internal/control/controller.go`, and `internal/agent/save.go` — to pass `session_id` through the hook payload and strip `` from saved user messages.\n\n---\n\n## 8. Portable Toolkit: Reasonix PortaKit\n\n**Repo**: [github.com/CS-Faith/reasonix-portakit](https://github.com/CS-Faith/reasonix-portakit) \n**Stars**: ⭐8 | **Language**: PowerShell + JavaScript + Batch | **License**: MIT \n**Latest Release**: v1.0.0 — June 8, 2026\n\nMakes Reasonix **truly portable** — USB drive or cloud-sync folder, plug into any Windows machine, and all memories, conversations, skills, and MCP configurations come with you.\n\n### The Problem\n\n| Symptom | Root Cause |\n|---------|------------|\n| Memories vanish | Project memory directory hashed by workspace path; path changes → hash mismatch |\n| Conversation history blank | Session metadata stores old workspace path; mismatch → all filtered out |\n| Skills / MCP broken | `config.json` hardcodes MCP paths with old drive letter (E:\\Reasonix) |\n| Nothing reads from here | Reasonix defaults to `C:\\Users\\...`, never looks at current directory |\n\n### How It Works\n\n```\n双击 启动Reasonix.bat\n │\n ├─ 1. Auto-detect current path (any drive, any folder)\n ├─ 2. Tell Reasonix: \"Your data is here, not in C:\\Users\\...\"\n ├─ 3. SHA1(path) → project hash → create dirs; auto-rename/merge old hash dirs\n ├─ 4. Merge local machine's Reasonix data (newer-only, no overwrites)\n ├─ 5. Patch config.json — old paths → current path\n ├─ 6. Repair all session metadata (strip BOM + fix JSON escaping + align workspace)\n └─ 7. Launch Reasonix\n```\n\n### Files\n\n| File | Size | Purpose |\n|------|------|---------|\n| `启动Reasonix.bat` | 0.6 KB | Entry point |\n| `_patch_config.ps1` | 6 KB | Core engine |\n| `_fix_sessions.js` | 2.7 KB | Session metadata repair |\n| `PortaKit-核心规则.md` | 2.4 KB | Core rules for Reasonix to remember portable setup |\n\n### Usage\n\n1. Ensure Reasonix has been launched at least once (API key configured)\n2. Copy PortaKit files into the Reasonix directory (next to `reasonix-desktop.exe`)\n3. Always launch via `启动Reasonix.bat` — never double-click `reasonix-desktop.exe` directly\n4. (Recommended) Import `PortaKit-核心规则.md` into Reasonix so it learns the portable mechanism\n5. After one bat launch, the entire folder becomes fully portable:\n - USB drive → any Windows PC → double-click bat → everything intact\n - Sync folder (OneDrive, Dropbox, etc.) → new PC sync → double-click bat → local data merged in\n\n---\n\n## 9. Protocol Landscape: MCP / ACP / CDP\n\n### MCP (Model Context Protocol) — ✅ Well Established\n\nMCP is the **dominant extension protocol** in the Reasonix ecosystem. Reasonix is a first-class MCP client with three transport types:\n\n- **`stdio`** (default) — launches a local subprocess; one JSON message per line over stdin/stdout\n- **`http`** (Streamable HTTP) — remote server at a URL; POST with optional SSE stream\n- **`sse`** — legacy 2024-11-05 transport; recognized but deferred (deprecated upstream)\n\nTools surface to the model as `mcp____`; a tool declaring MCP's `readOnlyHint: true` joins parallel dispatch. MCP prompts appear as `/mcp____` slash commands; MCP resources are pulled in via `@:`.\n\n**`.mcp.json` drop-in**: Reasonix reads a project-root `.mcp.json` using Claude Code's exact `mcpServers` schema — an existing Claude Code MCP config works in Reasonix unchanged.\n\n**`/mcp`** — Lists connected servers and what each exposes.\n\n| Integration | Type | MCP Tools |\n|-------------|------|-----------|\n| reasonix-mcp-server | Claude Code ↔ Reasonix | `reasonix_run`, `reasonix_doctor` |\n| reasonix-bridge | Codex ↔ DeepSeek | `execute_task`, `plan_task`, `orchestrate_task`, etc. |\n| opencode-reasonix-mcp | OpenCode ↔ Reasonix | Task dispatch |\n| Hindsight-Reasonix (MCP mode) | Memory retrieval | `hindsight_recall`, `hindsight_reflect`, `hindsight_retain` |\n| codegraph (built-in) | Semantic code index | Daemon process with MCP interface |\n\nReasonix has **native MCP support** built into its TOML config:\n\n```toml\n[[plugins]]\nname = \"hindsight\"\ncommand = \"python\"\nargs = [\"/path/to/hindsight_mcp.py\"]\n\n# Remote MCP over Streamable HTTP\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n```\n\nThe reference MCP stdio plugin (`cmd/reasonix-plugin-example`) ships as a runnable example with `echo`, `wordcount`, a `review` prompt, and a `style-guide` resource.\n\n### ACP (Agent Communication Protocol) — ✅ Implemented (v1.6.0+)\n\n**Confirmed in the v1.6.0 changelog** (Jun 10, 2026):\n\n```\nfix(cli): disable codegraph in ACP session config test (#3663)\n```\n\nThis confirms Reasonix has a native **ACP (Agent Communication Protocol)** implementation. ACP sessions run with codegraph indexing disabled to avoid daemon interference, which means ACP is a distinct session mode with its own constraints.\n\nOther ACP-adjacent projects and patterns in the ecosystem:\n\n- **[collab-cli](https://github.com/Staryinsang0910-star/collab-cli)** (⭐7) — Universal collaboration protocol + CLI for multi-agent LLM teams. Supports Claude Code, Reasonix, Codex, WorkBuddy, and Cursor as peers in a shared agent mesh. Defines how heterogeneous agents discover each other, delegate work, share context, and synchronize results.\n- **reasonix-bridge** — The \"Scientist Team\" pattern defines role-based inter-agent communication (Dijkstra ↔ Hopper ↔ Turing, etc.) with interface contract extraction between parallel agents.\n- **council skill** (awesome-reasonix) — Multi-agent discussion and decision-making pattern.\n- **Two-model collaboration** (Reasonix native) — The Coordinator runs an executor and planner in separate cache-stable sessions, handing off structured plans between them — a form of intra-tool agent communication.\n\n### CDP (Context Data Protocol) — 🔶 Emerging\n\nNo project explicitly branded \"CDP\" was found. The closest implementations:\n\n- **[Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix)** — Provides cross-session context persistence via MCP tools (`hindsight_recall`, `hindsight_retain`, `hindsight_reflect`). Functions as a context data layer that survives across sessions.\n- **Reasonix's built-in memory** (`.reasonix/memory.md`) — Persistent across sessions, loaded at startup.\n- **Reasonix's semantic index** — Codebase-wide context for faster file discovery.\n\n---\n\n## 10. Use Cases & Workflows\n\n### 10.1 Low-Cost AI Coding (Daily Development)\n\n**Stack**: Reasonix core (DeepSeek V4 Flash default)\n\nThe primary use case. Point Reasonix at a codebase, describe what you want, and it reads files, writes code, runs commands, and iterates. Costs $0.03–$0.15 per typical session thanks to 99.8% prefix cache hits.\n\n**Best for**: Feature development, bug fixing, refactoring, code review, test generation.\n\n```bash\ncd your-project\nreasonix chat\n> Add input validation to the signup form. Email must be valid, password minimum 8 chars.\n> Also add a confirm password field that must match.\n```\n\n### 10.2 Hybrid Claude Code + DeepSeek Workflow\n\n**Stack**: Claude Code + reasonix-mcp-server + Reasonix (DeepSeek)\n\nUse Claude Code for high-level reasoning, architecture design, and complex orchestration. Route the heavy file-editing work to Reasonix/DeepSeek via MCP to save ~80% on token costs.\n\n**Best for**: Teams already using Claude Code who want to reduce API spend without sacrificing quality.\n\n```\nClaude Code (orchestrator)\n → reasonix_run(\"refactor auth module to use JWT\")\n → Reasonix/DeepSeek (executor, $0.05)\n → Claude reviews the diff\n → reasonix_run(\"add tests for the new JWT module\")\n```\n\n### 10.3 Multi-Agent Parallel Development\n\n**Stack**: Codex + reasonix-bridge + Scientist Team\n\nCodex delegates complex multi-module tasks to reasonix-bridge, which automatically decomposes the work and assigns parallel sub-agents (Dijkstra for backend, Hopper for frontend, Turing for database, etc.).\n\n**Best for**: Full-stack feature development, multi-module refactors, greenfield project scaffolding.\n\n```\nCodex → execute_task(\"Build a blog system with backend API, frontend UI, and database\")\n → Bridge auto-detects complexity → multi-agent mode\n ├─ Dijkstra (backend): API routes, controllers, services\n ├─ Hopper (frontend): React components, pages, routing\n └─ Turing (database): schema, migrations, queries\n → Parallel execution, interface contracts extracted\n → All results merged\n```\n\n### 10.4 CI/CD Automation\n\n**Stack**: `reasonix run \"task\"` (non-interactive, piped input)\n\nThe `run` mode is designed for automation. It accepts piped input, runs non-interactively, and exits with appropriate status codes.\n\n**Best for**: Automated code review, test generation, documentation updates, security scanning.\n\n```bash\n# In CI pipeline:\ngit diff HEAD~1 | reasonix run \"review these changes for security issues\"\nreasonix run \"generate documentation for the ./api directory\"\nreasonix run \"update CHANGELOG.md based on git log since last tag\"\n```\n\n### 10.5 Cross-Session Persistent Memory\n\n**Stack**: Hindsight-Reasonix (hooks + MCP)\n\nEvery conversation turn is automatically recalled (pre-turn) and retained (stop). Memory persists across sessions without manual management. The optional MCP server enables active search.\n\n**Best for**: Long-running projects where institutional knowledge must accumulate over weeks/months; onboarding new agents to existing codebases.\n\n```\nSession 1: \"Set up the project structure\"\n → Hindsight retains: architecture decisions, tech stack, conventions\n\nSession 2 (next day): \"Add the payment module\"\n → Hindsight recalls previous session's context\n → LLM sees it automatically as \n → No need to re-explain the project structure\n```\n\n### 10.6 Portable Development Environment\n\n**Stack**: Reasonix PortaKit + USB drive / cloud sync\n\nCarry your entire Reasonix environment (memories, skills, MCP config, conversations) on a USB drive. Plug into any Windows machine, double-click the bat file, and everything is exactly where you left it.\n\n**Best for**: Developers who work across multiple machines (work PC, home PC, laptop); freelancers; educators.\n\n### 10.7 Community Skill Extensions\n\n**Stack**: awesome-reasonix skills\n\nExtend Reasonix with 80+ community-contributed skills covering everything from file organization to multi-agent councils. New skills can be installed in seconds.\n\n**Best for**: Customizing Reasonix for specific workflows, languages, or domains.\n\n```bash\n# Install\ncurl -o .reasonix/skills/deep-research.md https://raw.githubusercontent.com/.../deep-research.md\n# Use\n> /skill deep-research \"Compare the top 5 Rust web frameworks\"\n```\n\n### 10.8 Multi-Agent Collaboration Protocol\n\n**Stack**: collab-cli\n\nUse multiple different agent systems (Claude Code + Reasonix + Codex + Cursor) in a coordinated mesh, each playing to its strengths.\n\n**Best for**: Teams using heterogeneous agent tooling; complex pipelines requiring specialized agents.\n\n---\n\n## 11. Appendix: Quick-Reference Links\n\n### Core\n\n| Resource | URL |\n|----------|-----|\n| Reasonix Main Repo (v1.0 Go) | [github.com/esengine/DeepSeek-Reasonix](https://github.com/esengine/deepseek-reasonix) |\n| Official Website | [reasonix.homes](https://reasonix.homes) |\n| DeepSeek Official Docs | [api-docs.deepseek.com/.../reasonix](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix) |\n| npm Package | [npmjs.com/package/reasonix](https://www.npmjs.com/package/reasonix) |\n| brew Formula | `brew install esengine/reasonix/reasonix` |\n| Architecture Docs | [esengine.github.io/DeepSeek-Reasonix](https://esengine.github.io/DeepSeek-Reasonix/) |\n\n### Official Docs (in repo)\n\n| Document | URL |\n|----------|-----|\n| GUIDE.md (Configuration & Usage) | [github.com/.../docs/GUIDE.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md) |\n| GUIDE.zh-CN.md (简体中文指南) | [github.com/.../docs/GUIDE.zh-CN.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.zh-CN.md) |\n| SPEC.md (Engineering Contract) | [github.com/.../docs/SPEC.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SPEC.md) |\n| CHECKPOINTS.md (Rewind Design) | [github.com/.../docs/CHECKPOINTS.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/CHECKPOINTS.md) |\n| MIGRATING.md (0.x → 1.0) | [github.com/.../docs/MIGRATING.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/MIGRATING.md) |\n| REASONIX.md (Project Memory) | [github.com/.../REASONIX.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/REASONIX.md) |\n| SESSION_REFERENCE_ARCHITECTURE.md | [github.com/.../docs/SESSION_REFERENCE_ARCHITECTURE.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SESSION_REFERENCE_ARCHITECTURE.md) |\n\n### Community\n\n| Resource | URL |\n|----------|-----|\n| Discord (bilingual) | [discord.gg/XF78rEME2D](https://discord.gg/XF78rEME2D) |\n| GitHub Issues | [github.com/.../issues](https://github.com/esengine/deepseek-reasonix/issues) (266 open) |\n| GitHub Discussions | [github.com/.../discussions](https://github.com/esengine/deepseek-reasonix/discussions) |\n| Pull Requests | [github.com/.../pulls](https://github.com/esengine/deepseek-reasonix/pulls) (48 open) |\n\n### MCP Bridges\n\n| Resource | URL |\n|----------|-----|\n| reasonix-mcp-server (Claude Code ←→ Reasonix) | [github.com/Belveth02/reasonix-mcp-server](https://github.com/Belveth02/reasonix-mcp-server) |\n| reasonix-bridge (Codex ←→ DeepSeek) | [github.com/OwlCodeTech/reasonix-bridge](https://github.com/OwlCodeTech/reasonix-bridge) |\n| opencode-reasonix-mcp | [github.com/StarWHAT-BUG/opencode-reasonix-mcp](https://github.com/StarWHAT-BUG/opencode-reasonix-mcp) |\n| collab-cli (Universal Protocol) | [github.com/Staryinsang0910-star/collab-cli](https://github.com/Staryinsang0910-star/collab-cli) |\n\n### Skills & Community\n\n| Resource | URL |\n|----------|-----|\n| awesome-reasonix (Hub) | [github.com/hikari-2424/awesome-reasonix](https://github.com/hikari-2424/awesome-reasonix) |\n| Skills Site (Browse) | [hikari-2424.github.io/awesome-reasonix-site](https://hikari-2424.github.io/awesome-reasonix-site/) |\n| Full Registry JSON | [github.com/.../registry.json](https://github.com/hikari-2424/awesome-reasonix/blob/main/registry.json) |\n\n### Memory & Portability\n\n| Resource | URL |\n|----------|-----|\n| Hindsight-Reasonix (Memory) | [github.com/houycth/Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix) |\n| Reasonix PortaKit | [github.com/CS-Faith/reasonix-portakit](https://github.com/CS-Faith/reasonix-portakit) |\n\n### Guides & Comparisons\n\n| Resource | URL |\n|----------|-----|\n| Complete Guide (aimadetools) | [aimadetools.com/blog/reasonix-complete-guide](https://www.aimadetools.com/blog/reasonix-complete-guide) |\n| Setup Guide | [aimadetools.com/blog/how-to-use-reasonix](https://www.aimadetools.com/blog/how-to-use-reasonix) |\n| Reasonix vs Claude Code | [aimadetools.com/...](https://www.aimadetools.com/blog/reasonix-vs-claude-code) |\n| Reasonix vs Aider | [aimadetools.com/...](https://www.aimadetools.com/blog/reasonix-vs-aider-for-deepseek) |\n\n### Distribution\n\n| Channel | Details |\n|---------|---------|\n| npm | `npm i -g reasonix` (pulls prebuilt Go binary) |\n| Homebrew | `brew install esengine/reasonix/reasonix` |\n| Prebuilt Archives | 6 targets: darwin/linux/windows × amd64/arm64 + SHA256SUMS |\n| Debian | `.deb` package built on release |\n| Source | `make build` (→ `bin/reasonix`) / `make cross` (→ `dist/`) |\n| Windows Signing | [SignPath Foundation](https://signpath.io/) (free certificate) |\n\n### Contributors & Acknowledgments\n\nTop contributors (alphabetical, by commit count + code volume): **ctharvey**, **dimasd-angga**, **Evan-Pycraft**, **ForeverYoungPp**, **GTC2080**, **kabaka9527**, **lisniuse**, **wade19990814-hue**, **wviana**.\n\nLogo by **Bernardxu123**. Community promotion on XiaoHongShu (小红书) by **AIGC Link**.\n\n### Donations\n\nReasonix is MIT-licensed and free. Donations stay \"a coffee, not a contract\" — they don't buy feature priority or change issue triage. [PayPal: paypal.me/yuhuahui](https://paypal.me/yuhuahui) · WeChat Pay (QR in README).\n\n---\n\n> **Document**: Updated Jun 2026 from primary source `esengine/DeepSeek-Reasonix` (main-v2, v1.10.0, Jun 2026), community GitHub repos, DeepSeek API docs, npm, aimadetools, reasonix.homes. \n> **Note**: The \"Reasonix\" name also refers to [reasonixos.com](https://reasonixos.com/about) — an \"Integrated Intelligence Company\" (AI-powered enterprise decision systems / AI Growth OS for marketing). This document covers only the DeepSeek-native coding agent ecosystem, not the enterprise platform.\n"}, "8387e509b27da07c0f3164a736b7456ccf4ac507": {"t": 1785750396.8121226, "n": 53168, "h": "71745231fba9c0b7", "c": "# Reasonix + DeepSeek Ecosystem: Complete Reference (2026)\n\n> A comprehensive survey of integrations, plugins, tools, skills, MCP bridges, protocols, and use cases in the Reasonix ecosystem as of mid-2026.\n\n---\n\n## Table of Contents\n\n1. [Overview: What is Reasonix?](#1-overview-what-is-reasonix)\n2. [Core Architecture & Cost Model](#2-core-architecture--cost-model)\n3. [Official Integration (DeepSeek Docs)](#3-official-integration-deepseek-docs)\n4. [Desktop Application v1.6.0](#4-desktop-application-v150)\n - 4.1 [Key Desktop Features](#key-desktop-features)\n - 4.2 [Bot Gateway (Feishu / Weixin / QQ)](#bot-gateway-v150)\n - 4.3 [Desktop vs CLI](#desktop-vs-cli)\n5. [MCP Bridges & Cross-Platform Integrations](#5-mcp-bridges--cross-platform-integrations)\n - 5.1 [reasonix-mcp-server (Claude Code ↔ Reasonix)](#51-reasonix-mcp-server-claude-code--reasonix)\n - 5.2 [reasonix-bridge (Codex ↔ DeepSeek, Multi-Agent)](#52-reasonix-bridge-codex--deepseek-multi-agent)\n - 5.3 [opencode-reasonix-mcp (OpenCode Bridge)](#53-opencode-reasonix-mcp-opencode-bridge)\n - 5.4 [collab-cli (Universal Multi-Agent Protocol)](#54-collab-cli-universal-multi-agent-protocol)\n6. [Community Skills Hub (awesome-reasonix)](#6-community-skills-hub-awesome-reasonix)\n - 6.1 [Full Skill Registry](#61-full-skill-registry)\n - 6.2 [Skill Categories](#62-skill-categories)\n7. [Long-Term Memory: Hindsight-Reasonix](#7-long-term-memory-hindsight-reasonix)\n8. [Portable Toolkit: Reasonix PortaKit](#8-portable-toolkit-reasonix-portakit)\n9. [Protocol Landscape: MCP / ACP / CDP](#9-protocol-landscape-mcp--acp--cdp)\n10. [Use Cases & Workflows](#10-use-cases--workflows)\n11. [Appendix: Quick-Reference Links](#11-appendix-quick-reference-links)\n\n---\n\n## 1. Overview: What is Reasonix?\n\n**Reasonix** is an open-source (MIT), terminal-based AI coding agent built specifically for **DeepSeek models**. It is listed on DeepSeek's official API documentation as a recommended integration, giving it a degree of first-party endorsement that few third-party tools have.\n\n| Attribute | Value |\n|-----------|-------|\n| **License** | MIT (fully open source) |\n| **Language** | Go (1.0, `main-v2` branch — current); TypeScript (0.x, `v1` branch — legacy/maintenance) |\n| **GitHub Stars** | 20,700+ |\n| **Forks** | 1,200+ |\n| **Releases** | 50+ (v1.10.0 — Jun 2026) |\n| **Commits** | 777+ |\n| **Default Model** | DeepSeek V4 Flash (via config TOML); also presets for DeepSeek V4 Pro, MiMo-v2.5-pro, MiMo-v2.5 |\n| **Upgrade Model** | DeepSeek V4 Pro or MiMo-v2.5-pro (per-model via `/model` or `/preset max`) |\n| **Platforms** | macOS (brew + prebuilt), Linux (.deb + prebuilt), Windows (prebuilt, code-signed via SignPath) |\n| **Config Format** | TOML (`reasonix.toml` — flag > project > user > defaults resolution) |\n| **Node Requirement** | None for v1.0 Go binary; Node ≥ 22 only for npm install shim or legacy 0.x |\n| **Alias** | `dsnix` |\n| **Install** | `npm i -g reasonix`, `brew install esengine/reasonix/reasonix`, prebuilt archives (6 targets), `make build`/`make cross` from source |\n| **Official Docs** | [DeepSeek Agent Integrations](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix); [Guide (GUIDE.md)](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md); [Spec (SPEC.md)](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SPEC.md) |\n\n### Three Operating Modes\n\n| Mode | Command | Description |\n|------|---------|-------------|\n| **chat** (default) | `reasonix chat` | Interactive conversation with full tool access. Run `/init` to generate AGENTS.md project memory. Use for actual development. |\n| **run** (one-shot) | `reasonix run \"task\"` | Executes a single task and exits. Accepts piped input and `--model` flag. Ideal for CI/CD, scripting, automation pipelines. |\n| **code** | `reasonix chat` | Legacy alias preserved from 0.x; routes to `chat` in v1.0+. |\n\n### Model Switching\n\n- **`/model`** — Interactively switch the active model mid-session; selection persists to user config.\n- **`/preset max`** — Switches the entire session to a higher-tier model (e.g. DeepSeek V4 Pro).\n- **Two-model collaboration** — Set `[agent].planner_model` in `reasonix.toml` to run a separate low-frequency planner (e.g. MiMo-v2.5-pro) alongside the executor, each in its own cache-stable session.\n- **Subagent models** — `subagent_model` and `subagent_models` config keys let specific skills (like `review`, `security_review`) run on different models.\n- **Multi-provider presets** — DeepSeek (flash/pro) and MiMo (mimo-v2.5-pro / mimo-v2.5) ship as config presets; any OpenAI-compatible endpoint is a config entry.\n\n### Built-in Features\n\n- **Plan mode** — Auto-plan (`/auto-plan on`) or manual (`/plan`); two-model planner with read-only research tools before executor acts.\n- **Checkpoints & Rewind** — File-snapshot-based undo (`Esc-Esc` or `/rewind`); restore code, conversation, or both; fork-from-here; works without touching `.git`.\n- **Goal mode** — `/goal ` starts an autonomous, session-scoped active goal loop with blocked-state audit; desktop-only.\n- **Memory** — Hierarchical `REASONIX.md` (committed) + `REASONIX.local.md` (personal, gitignored) + user-global `~/.config/reasonix/REASONIX.md` + ancestor dirs; `AGENTS.md` accepted as fallback.\n- **`remember` tool** — Saves durable facts to per-project auto-memory (frontmatter files + `MEMORY.md` index), loaded into prefix on next session.\n- **`#` quick-add** — Type `#` in chat to quick-add a line to the project's `REASONIX.md`.\n- **`@path` imports** — A line containing `@path/to/another/file.md` injects that file's content into the memory prefix.\n- **Session branching** — `/tree` shows saved conversation branches; `/branch [name]` forks current tip; `/branch [name]` forks from a checkpointed turn; `/switch ` loads another branch.\n- **Subagent transcripts** — `/task` spawns sub-agents; continue/resume past transcripts in v1.6.0+.\n- **MCP/Plugins** — First-class MCP client: stdio, HTTP (Streamable), and SSE transports; `.mcp.json` drop-in from Claude Code works unchanged.\n- **Skills** — Reusable prompt templates in `.reasonix/skills/*.md`; frontmatter supports `runAs: subagent` and `allowed-tools` for isolated execution; `/skill new my-skill` scaffolds one.\n- **`read_skill` tool** — Load inline skills in plan mode without execution (v1.6.0+).\n- **Slash commands** — 20+ built-ins (`/compact`, `/new`, `/clear`, `/rewind`, `/tree`, `/branch`, `/switch`, `/todo`, `/model`, `/mcp`, `/skills`, `/hooks`, `/memory`, `/output-style`, `/sandbox`, `/language`, `/auto-plan`, `/help`); custom commands in `.reasonix/commands/*.md`.\n- **Chat references (`@`)** — `@path/to/file` injects file contents (or directory listing) as tagged context; `@:` injects MCP resources. Autocomplete on `/` or `@`.\n- **Web search** — Mojeek, SearXNG, or Metaso backends.\n- **Transcript replay & events** — Every event hits disk; `reasonix replay`, `reasonix events`, `reasonix stats`.\n- **Bot gateway** — Feishu/Weixin/QQ adapters (v1.6.0+).\n- **PDF extraction** — Extract text from PDF attachments (v1.6.0+).\n- **Sandbox** — macOS Seatbelt enforcement for Bash (jailed to workspace + temp + toolchain caches, network opt-in); file-writers confined to `workspace_root` with symlink/`..` traversal prevention; Linux bubblewrap/landlock on roadmap.\n- **Permisssions** — `deny > ask > allow > fallback` per-tool-call gating; tool-approval postures: ask, auto, yolo.\n- **Context compaction** — Low-frequency cache-reset point when approaching `context_window` limit; older middle of session summarized; dropped originals archived as JSONL.\n- **Codegraph** — Semantic code index for faster file discovery (daemon process with MCP interface).\n\n---\n\n## 2. Core Architecture & Cost Model\n\n### Cache-First Philosophy\n\nReasonix is engineered around **DeepSeek's byte-stable prefix cache**. Every LLM API provider offers prefix caching, but DeepSeek's is particularly aggressive — if the start of a prompt matches a cached prefix exactly, the input token cost drops to ~1/5th the normal rate (per official reasonix.homes: ~94% cache hit, ~2.5× cost reduction). Any change invalidates the cache.\n\nGeneric tools restructure prompts for correctness. Reasonix structures them for **cache stability**:\n\n1. **Fixed prefix ordering** — System prompt, project context, and memory are always in the same position. New information is appended, never inserted.\n2. **Incremental context growth** — Files added to context go at the end. Nothing before them shifts.\n3. **Stable tool call formatting** — Tool results are formatted identically every time.\n\n### Real-World Cost Numbers\n\n| Session Type | Input Tokens | Cache Hit Rate | Effective Cost | Without Cache |\n|:-------------|:-------------|:---------------|:---------------|:--------------|\n| Quick fix (5 min) | 50K | 99.5% | ~$0.01 | ~$0.04 |\n| Feature (30 min) | 500K | 99.8% | ~$0.05 | ~$0.24 |\n| Refactor (2 hrs) | 3M | 99.9% | ~$0.15 | ~$1.31 |\n| Full day coding | 20M | 99.8% | ~$0.85 | ~$4.12 |\n\n**Headline stat**: 435M input tokens processed with 99.82% cache hits, costing approximately **$12** instead of the **$61** it would have cost without cache optimization. (Real production data, not a benchmark.)\n\n### Configuration\n\n**Resolution order**: `flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults`.\n\nSecrets come from the environment via `api_key_env` and are **never stored in config files**. A `.env` in the working directory is loaded if present. Step-limit preferences belong in user config; project `reasonix.toml` should override them only when the repository needs shared runtime bounds.\n\n**Minimal `reasonix.toml`** — one provider and a default model is enough to start:\n\n```toml\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n```\n\n**Full example**:\n\n```toml\ndefault_model = \"deepseek-flash\" # executor; set [agent].planner_model to add a planner\n\n[agent]\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\n# planner_model = \"mimo-pro\" # optional low-frequency planner\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" }\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n\n[tools]\nenabled = [] # omit/empty = all built-ins\nbash_timeout_seconds = 120\n\n[skills]\npaths = [] # extra custom skill roots\nexcluded_paths = [] # hide convention roots without deleting\ndisabled_skills = [] # hidden from prompt, slash invocation, and skill tools\n\n[permissions]\nmode = \"ask\" # writer fallback: ask|allow|deny\ndeny = [\"Bash(rm -rf*)\", \"Bash(git push*)\"] # hard-blocked in every mode\nallow = [\"Bash(go test:*)\"] # never prompted\n\n[sandbox]\n# workspace_root = \"\" # file-writers confined here; empty = current dir\n# allow_write = [\"/tmp\"]\n\n[[plugins]]\nname = \"github\"\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-github\"]\n\n# Remote MCP server over Streamable HTTP\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n```\n\n**`.mcp.json` drop-in support**: Reasonix also reads a project-root `.mcp.json` using Claude Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`, `${VAR}` expansion). Both sources are merged; on a name collision `reasonix.toml` wins.\n\n```json\n{\n \"mcpServers\": {\n \"filesystem\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/path\"] },\n \"stripe\": { \"type\": \"http\", \"url\": \"https://mcp.stripe.com\", \"headers\": { \"Authorization\": \"Bearer ${STRIPE_KEY}\" } }\n }\n}\n```\n\n---\n\n## 3. Official Integration (DeepSeek Docs)\n\nDeepSeek's official API documentation includes Reasonix as a **recommended agent integration** alongside Claude Code, GitHub Copilot, WorkBuddy, OpenCode, Kilo Code, and others.\n\n**Installation**:\n\n```bash\n# Any OS — pulls the prebuilt Go native binary\nnpm i -g reasonix\n\n# macOS\nbrew install esengine/reasonix/reasonix\n\n# Prebuilt archives for all platforms (darwin|linux|windows × amd64|arm64)\n# Available on every GitHub release, with SHA256SUMS\n\n# Build from source\ngit clone https://github.com/esengine/deepseek-reasonix.git\ncd deepseek-reasonix\nmake build # -> bin/reasonix(.exe)\nmake cross # -> dist/ (6 targets)\n```\n\nWindows builds are **code-signed** with a free certificate provided by the [SignPath Foundation](https://signpath.io/). Linux `.deb` packages are built on release.\n\n**Quick-start** (from [deepseek.com](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix)):\n\n```bash\n# 1. Get a DeepSeek API Key from platform.deepseek.com\n# 2. Run the config wizard:\nreasonix setup # writes ./reasonix.toml\n\n# 3. Start chatting:\nexport DEEPSEEK_API_KEY=sk-... # or put it in .env\nreasonix chat # then run /init to generate AGENTS.md\n\n# Or one-shot:\nreasonix run \"implement the TODOs in main.go\"\nreasonix run --model mimo-pro \"add unit tests for this function\"\necho \"explain this code\" | reasonix run\n```\n\nNo Node.js required for v1.0+ (single Go binary). First launch with `reasonix setup` writes a minimal `reasonix.toml`. Secrets stay in environment variables (`api_key_env`), never in config files.\n\n---\n\n## 4. Desktop Application v1.10.0\n\n**Built into the main repo**: [desktop/](https://github.com/esengine/deepseek-reasonix/tree/main-v2/desktop) directory in `esengine/DeepSeek-Reasonix`. \n**Stack**: Wails + React 19 + TypeScript 6 | **Latest**: v1.10.0 (Jun 2026)\n\nThe desktop app is a **visual companion to the CLI**, not a Cursor replacement. It provides a GUI wrapper around the same Go controller — the terminal TUI, desktop webview, and HTTP/SSE server all drive the same `control.Controller`, so every feature surfaces identically across frontends.\n\n### Key Desktop Features\n\n- **Themed workspace UI** — Dark and light themes with a full sidebar: session list, file tree, MCP settings panel, skills browser, and memory view. Screenshots at `docs/desktop-theme-dark.jpg` and `docs/desktop-theme-light.jpg`.\n- **Collaboration-mode picker** — Three choices: **normal** (standard chat), **plan** (`/plan`), and **goal** (`/goal`).\n- **Goal mode** (`/goal `) — Starts an autonomous, session-scoped active goal loop. The controller prepends goal context to user turns (outside the cache-stable system prompt) and keeps issuing continuation turns until the model reports completion, repeats the same blocked state three times, or the safety limit is reached. Blocked-state matching is normalized for casing, whitespace, and punctuation. `/goal clear` removes the active goal.\n- **Tool-approval postures** — **ask** (each writer/bash call prompts), **auto** (writer fallback auto-allowed, explicit ask/deny rules still apply), **yolo** (approval prompts auto-allowed unless denied). These are separate from the collaboration-mode picker.\n- **`read_skill` tool** — Load inline skills in plan mode without execution (v1.6.0+).\n- **Session workspace isolation** — Desktop sessions are isolated per workspace directory; switching workspaces loads the correct session history.\n- **MCP settings panel** — View connected servers, connection status, retry failures, inspect each server's tools/prompts/resources. Collapses noisy startup errors into a summary.\n- **Reveal-in-file-manager** — Right-click any file in the workspace tree to reveal it in Finder/Explorer.\n- **Image paste** — Paste clipboard images directly into the chat composer.\n- **Model selector dropdown** — Switch models interactively; scroll support and tooltips for truncated names; preserves curated provider models on refresh.\n- **Rewind (hover)** — Each user message in the transcript has a hover rewind control → menu: rewind code / rewind conversation / both / fork-from-here.\n- **Subagent transcript continue/resume** — Continue past subagent transcripts from where they left off (v1.6.0+).\n- **PDF attachment text extraction** — Extract text from PDF attachments dropped into the chat (v1.6.0+).\n\n### Bot Gateway (v1.6.0+)\n\nReasonix ships a **bot gateway** with adapters for:\n- **Feishu (飞书)** — Chinese enterprise messaging\n- **Weixin (微信)** — China's dominant messaging app\n- **QQ** — Tencent's instant messaging platform\n\nThe gateway receives messages from these platforms, routes them through the Reasonix agent, and sends responses back. Each adapter handles authentication (Feishu webhook tokens, Weixin account/login). This turns Reasonix into a deployable AI coding assistant accessible from chat platforms — useful for teams that want a shared coding agent reachable from their existing messaging tools.\n\n### Desktop vs CLI\n\n| Feature | CLI (TUI) | Desktop |\n|---------|-----------|---------|\n| Chat | Full transcript, scrolling (Ctrl+Home/End in v1.6.0) | Visual transcript with hover controls |\n| Plan mode | `/plan` | Collaboration-mode picker |\n| Goal mode | Not available | `/goal` with blocked-state audit |\n| Checkpoints/Rewind | `Esc-Esc` / `/rewind` picker | Hover rewind on each message |\n| MCP management | `/mcp` slash command | Full settings panel with retry |\n| Skills | `/skills` | Visual browser |\n| File tree | `/tree` | Sidebar with reveal-in-file-manager |\n| Image paste | Via file path | Clipboard paste |\n| Bot gateway | Available via serve mode | Included |\n\n---\n\n## 5. MCP Bridges & Cross-Platform Integrations\n\n### 5.1 reasonix-mcp-server (Claude Code ↔ Reasonix)\n\n**Repo**: [github.com/Belveth02/reasonix-mcp-server](https://github.com/Belveth02/reasonix-mcp-server) \n**Stars**: ⭐2 | **Language**: JavaScript | **License**: MIT\n\nThis MCP server lets **Claude Code call Reasonix as a DeepSeek sub-agent** through the Model Context Protocol. The core idea: use Claude for high-level reasoning and orchestration, but delegate the actual file-editing work to Reasonix (which costs ~1/5 as much thanks to prefix caching).\n\n#### Tools Exposed\n\n| Tool | Parameters | Description |\n|------|------------|-------------|\n| `reasonix_run` | `task` (string, required), `model` (string, optional), `effort` (enum: low/medium/high/max), `budget` (number, optional), `timeout` (number, default 300000ms) | Execute a coding task via Reasonix |\n| `reasonix_doctor` | `json` (boolean, optional) | Run health check: API key, network, config |\n\n#### Installation\n\n```bash\nnpm install -g reasonix\ngit clone https://github.com/Belveth/reasonix-mcp-server.git\ncd reasonix-mcp-server\nnpm install\n```\n\nRegister with Claude Code:\n\n```bash\nclaude mcp add reasonix --scope user -- \\\n --command node \\\n --args \"/absolute/path/to/server.js\"\n```\n\nOr manually edit `~/.claude.json`:\n\n```json\n\"mcpServers\": {\n \"reasonix\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\"C:\\\\Users\\\\xxx\\\\.claude\\\\tools\\\\reasonix-mcp-server\\\\server.js\"],\n \"env\": {}\n }\n}\n```\n\n#### Cost Comparison\n\nIn real-world testing: 435M tokens processed at ~$12 via Reasonix/DeepSeek. Equivalent work on Claude: ~$61. That's an **~80% cost reduction** by routing the heavy file-editing work through Reasonix's cache-optimized DeepSeek pipeline.\n\n---\n\n### 5.2 reasonix-bridge (Codex ↔ DeepSeek, Multi-Agent)\n\n**Repo**: [github.com/OwlCodeTech/reasonix-bridge](https://github.com/OwlCodeTech/reasonix-bridge) \n**Stars**: ⭐6 | **Language**: JavaScript (~1500 lines, single file) | **License**: MIT\n\nA **multi-agent parallel execution engine** that lets **Codex delegate tasks to DeepSeek via MCP**. Supports two-phase execution, multi-agent parallelism, incremental mode, and dynamic budget allocation.\n\n#### Architecture\n\n```\nCodex (MCP Client)\n │ stdio pipe\n ▼\nindex.js (MCP Server)\n │\n ├─ 6 MCP tools\n ├─ 8 local sandbox tools (read_file / write_file / run_command / ...)\n └─ DeepSeek API (streaming)\n ├─ Planning mode (no tools, produces plan)\n └─ Execution mode (8 tools, agent loop)\n```\n\n#### Tools Exposed\n\n| Tool | Purpose | Recommended Use |\n|------|---------|-----------------|\n| `execute_task` | Auto-detect: single or multi-agent | Daily use |\n| `plan_task` | Generate plan only, no execution | \"Show me the plan first\" |\n| `execute_step` | Execute a specific step | Pair with `plan_task` |\n| `orchestrate_task` | Force multi-agent parallelism | When explicit division of labor is needed |\n| `improve_file` | DeepSeek-targeted fix; Codex retains override | When Codex finds something to fix |\n| `delegate_to_reasonix` | Legacy one-shot execution | Backward compatibility |\n\n#### The \"Scientist Team\" (Role-Based Parallel Agents)\n\nWhen a task requires multi-agent execution, Bridge assigns roles automatically:\n\n| Emoji | Scientist | Role | Domain |\n|:-----|:----------|:-----|:--------|\n| 🖥️ | Dijkstra | Backend Engineer | Servers, APIs, algorithms |\n| 🎨 | Hopper | Frontend Developer | UI, interactions, components |\n| 🗄️ | Turing | Data Engineer | Database, storage |\n| 🔗 | Berners-Lee | Interface Engineer | API communication |\n| 🧪 | Lovelace | Test Engineer | Validation, deployment |\n\n**Sub-agent decomposition**: A single-domain complex task auto-splits:\n```\n🖥️ Dijkstra\n ├─ Dijkstra-A (user auth module)\n ├─ Dijkstra-B (CRUD operations)\n └─ Dijkstra-C (file upload)\n```\n\n#### Two-Phase Execution\n\n1. **Phase 1 (Planning)**: DeepSeek decomposes task → JSON step list (5-10 seconds)\n2. **Phase 2 (Execution)**: Step-by-step or multi-agent parallel (each step has independent context)\n - Previous outputs already on disk, unaffected by subsequent budget limits\n\n**Parallel scheduling**: Independent sub-tasks → `Promise.all` parallel execution. Dependent sub-tasks → wait for dependencies. Interface contracts extracted after each step and passed to subsequent agents.\n\n#### Security Model: 5-Level Command Control\n\n| Mode | Permissions |\n|------|-------------|\n| `off` | No commands, read-only files |\n| `readonly` | `pwd`, `echo`, `which` |\n| `static` | + `git` (read-only), `node --check`, `tsc` |\n| `verify` | + `npm test`, `eslint`, `jest` |\n| `full` | All open + write files |\n\nAdditional security: file sandbox (validates all paths, rejects traversal/symlink escapes), hash-based overwrite prevention, read-only mode auto-disables `edit_file`/`write_file`, forced-write audit logging, `REASONIX_MAX_TOKENS` hard cap.\n\n#### Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `DEEPSEEK_API_KEY` | — | Required |\n| `REASONIX_COMMAND_MODE` | `static` | Command permission level |\n| `WORKSPACE_ROOT` | `cwd` | Sandbox root directory |\n| `DEEPSEEK_MODEL` | `deepseek-chat` | Model identifier |\n| `DEEPSEEK_TEMPERATURE` | `0.2` | Temperature |\n| `REASONIX_MAX_TOKENS` | `64000` | Cumulative token budget |\n| `HTTP_PROXY` | — | Proxy (for non-China API access) |\n| `GITHUB_TOKEN` | — | GitHub API rate-limit boost |\n\n---\n\n### 5.3 opencode-reasonix-mcp (OpenCode Bridge)\n\n**Repo**: [github.com/StarWHAT-BUG/opencode-reasonix-mcp](https://github.com/StarWHAT-BUG/opencode-reasonix-mcp) \n**Stars**: ⭐0 | **Language**: JavaScript\n\nDispatches **stateless sub-tasks from OpenCode to Reasonix CLI** via MCP, saving ~99% of token costs compared to running the same tasks on Claude or GPT, by routing them through DeepSeek's prefix-cached pipeline.\n\n---\n\n### 5.4 collab-cli (Universal Multi-Agent Protocol)\n\n**Repo**: [github.com/Staryinsang0910-star/collab-cli](https://github.com/Staryinsang0910-star/collab-cli) \n**Stars**: ⭐7 | **Language**: TypeScript | **Tags**: `mcp`, `collaboration`, `protocol`, `multi-agent`\n\nA **universal collaboration protocol + CLI** for multi-agent LLM teams. Supports **Claude Code, Reasonix, Codex, WorkBuddy, and Cursor** as peers in a shared agent mesh. This is the closest project found to an **ACP (Agent Communication Protocol)** implementation — it defines how different agent systems discover each other, delegate work, share context, and synchronize results.\n\n---\n\n## 6. Community Skills Hub (awesome-reasonix)\n\n**Repo**: [github.com/hikari-2424/awesome-reasonix](https://github.com/hikari-2424/awesome-reasonix) \n**Stars**: ⭐2 | **Language**: HTML / Markdown | **License**: MIT \n**Site**: [hikari-2424.github.io/awesome-reasonix-site/](https://hikari-2424.github.io/awesome-reasonix-site/)\n\nAn **independent community project** (not affiliated with Reasonix) that collects, rates, and shares community-contributed Skills. Skills are Markdown files dropped into `.reasonix/skills/` and invoked via `/skill `.\n\n**Registry version**: 3 | **Last updated**: 2026-05-30 | **80+ published skills**\n\n### Installation\n\n```bash\n# Single skill\ncurl -o .reasonix/skills/file-organizer.md https://raw.githubusercontent.com/hikari-2424/awesome-reasonix/main/skills/file-organizer.md\n\n# Then inside Reasonix:\n> /skill file-organizer\n```\n\n### 6.1 Full Skill Registry\n\n#### File & System\n\n| Skill | Description |\n|-------|-------------|\n| `file-organizer` | Classify files by extension, find duplicates, tree view, clean empty dirs |\n| `disk-cleaner` | Disk space analysis and cleanup automation |\n\n#### Text Processing\n\n| Skill | Description |\n|-------|-------------|\n| `text-polish` | Polish, translate, summarize, rewrite — 4 output styles (professional/casual/concise/tech-doc) |\n| `data-convert` | Convert data between formats (JSON, CSV, YAML, etc.) |\n| `crosspost` | Cross-post content across platforms |\n\n#### Learning & Research\n\n| Skill | Description |\n|-------|-------------|\n| `learn-topic` | Search → fetch → organize → summarize (subagent mode) |\n| `deep-research` | Deep research agent with iterative retrieval |\n| `data-scraper-agent` | Web scraping agent |\n\n#### System Control\n\n| Skill | Description |\n|-------|-------------|\n| `input-control` | Windows mouse/keyboard automation via PowerShell |\n| `knowledge-ops` | Knowledge base management operations |\n\n#### Development Tools\n\n| Skill | Description |\n|-------|-------------|\n| `git-helper` | Conventional Commits, PR descriptions, history analysis, conflict resolution |\n| `code-review` | 4-dimension review (logic, security, performance, style) with file:line output |\n| `changelog-generator` | Auto-generate CHANGELOG.md from git history, semver inference |\n| `database-migrations` | Database migration patterns and automation |\n| `deployment-patterns` | Deployment strategies and CI/CD patterns |\n| `docker-patterns` | Docker and containerization patterns |\n| `github-ops` | GitHub operations automation |\n\n#### Project Generation\n\n| Skill | Description |\n|-------|-------------|\n| `project-scaffold` | Interactive scaffolding: Python, TypeScript, Go, Rust |\n| `readme-gen` | Auto-generate README.md from project structure analysis |\n| `api-connector-builder` | Build API connectors matching existing integration patterns |\n\n#### API & Integration\n\n| Skill | Description |\n|-------|-------------|\n| `api-design` | REST API design patterns: naming, status codes, pagination, filtering, error responses, versioning |\n| `jira-integration` | Jira issue tracking integration |\n\n#### Content & Design\n\n| Skill | Description |\n|-------|-------------|\n| `article-writing` | Write articles, guides, blog posts, tutorials, newsletters |\n| `brand-voice` | Extract writing style profiles from real content, build reusable brand voice config |\n| `code-tour` | Create step-by-step CodeTour files with real file:line anchors |\n| `dashboard-builder` | Build dashboards and data visualizations |\n| `frontend-slides` | Create HTML presentations from scratch or convert PowerPoint |\n| `content-engine` | Content generation and management engine |\n| `liquid-glass-design` | Modern UI design patterns |\n\n#### AI & Agent\n\n| Skill | Description |\n|-------|-------------|\n| `autonomous-loops` | Autonomous agent loop patterns: from simple pipelines to RFC-driven multi-agent workflows |\n| `blueprint` | Turn one-line objective into multi-phase, multi-agent engineering plan with dependency graph |\n| `council` | Multi-agent discussion and decision-making council |\n| `continuous-learning` | Auto-extract reusable patterns from Claude Code sessions |\n| `continuous-learning-v2` | Instinct-based learning system with confidence scoring |\n| `agentic-engineering` | Agent-driven engineering patterns |\n| `agent-harness-construction` | Build agent harnesses for testing and evaluation |\n| `agent-introspection-debugging` | Debug and introspect agent behavior |\n| `agent-sort` | Agent categorization and sorting |\n| `ai-first-engineering` | AI-first development methodology |\n| `eval-harness` | Formal evaluation framework for AI sessions |\n| `cost-aware-llm-pipeline` | Cost-optimized LLM pipeline patterns |\n| `iterative-retrieval` | Progressive context retrieval pattern |\n\n#### Architecture & Standards\n\n| Skill | Description |\n|-------|-------------|\n| `coding-standards` | Cross-project coding conventions: naming, readability, immutability, review criteria |\n| `backend-patterns` | Backend architecture: API design, database optimization, Node.js/Express/Next.js best practices |\n| `frontend-patterns` | Frontend patterns: React, Next.js, state management, performance |\n| `frontend-design` | UI/UX design patterns |\n| `android-clean-architecture` | Clean Architecture for Android/Kotlin Multiplatform |\n| `compose-multiplatform-patterns` | Compose Multiplatform / Jetpack Compose: state, navigation, theming, testing |\n| `dart-flutter-patterns` | Dart and Flutter patterns |\n| `kotlin-patterns` | Kotlin patterns and best practices |\n| `kotlin-coroutines-flows` | Coroutines and Flow patterns |\n| `kotlin-exposed-patterns` | JetBrains Exposed ORM patterns |\n| `kotlin-ktor-patterns` | Ktor framework patterns |\n| `java-coding-standards` | Java coding standards for Spring Boot |\n| `jpa-patterns` | JPA/Hibernate patterns |\n| `springboot-patterns` | Spring Boot architecture and patterns |\n| `django-patterns` | Django architecture patterns |\n| `django-security` | Django security best practices |\n| `laravel-patterns` | Laravel framework patterns |\n| `laravel-security` | Laravel security practices |\n| `golang-patterns` | Idiomatic Go patterns |\n| `cpp-coding-standards` | C++ Core Guidelines-based standards |\n| `dotnet-patterns` | .NET architecture patterns |\n| `perl-patterns` | Perl patterns and best practices |\n| `rust-patterns` | Rust patterns and best practices |\n| `clickhouse-io` | ClickHouse database patterns and optimization |\n\n#### Testing\n\n| Skill | Description |\n|-------|-------------|\n| `django-tdd` | Django testing with pytest-django, TDD methodology |\n| `django-verification` | Django verification loop: migrations, linting, tests, security |\n| `laravel-tdd` | Laravel testing patterns |\n| `laravel-verification` | Laravel verification loop |\n| `springboot-tdd` | Spring Boot testing patterns |\n| `springboot-verification` | Spring Boot verification loop |\n| `golang-testing` | Go testing: table-driven, subtests, benchmarks, fuzzing |\n| `cpp-testing` | C++ testing with GoogleTest/CTest |\n| `kotlin-testing` | Kotlin testing patterns |\n| `csharp-testing` | C# testing patterns |\n| `e2e-testing` | Playwright E2E testing, Page Object Model, CI/CD |\n| `ai-regression-testing` | Regression testing for AI-assisted development |\n\n#### Business & Operations\n\n| Skill | Description |\n|-------|-------------|\n| `carrier-relationship-management` | Carrier portfolio management, freight rate negotiation, performance tracking |\n| `customer-billing-ops` | Customer billing operations |\n| `customs-trade-compliance` | Customs and trade compliance |\n| `energy-procurement` | Energy procurement strategies |\n| `enterprise-agent-ops` | Enterprise agent operations |\n| `finance-billing-ops` | Finance and billing operations |\n| `inventory-demand-planning` | Inventory and demand planning |\n| `investor-materials` | Investor materials generation |\n| `investor-outreach` | Investor outreach automation |\n| `lead-intelligence` | Lead intelligence and scoring |\n| `logistics-exception-management` | Logistics exception handling |\n| `connections-optimizer` | Network/connections optimization |\n\n#### Security & Compliance\n\n| Skill | Description |\n|-------|-------------|\n| `defi-amm-security` | DeFi AMM security patterns |\n| `llm-trading-agent-security` | LLM trading agent security |\n| `evm-token-decimals` | EVM token decimal handling |\n| `healthcare-phi-compliance` | Healthcare PHI compliance |\n| `hipaa-compliance` | HIPAA compliance patterns |\n| `ecc-tools-cost-audit` | Tool cost auditing for ECC |\n\n### 6.2 Skill Categories\n\n| # | Category | Count |\n|---|----------|-------|\n| 1 | AI & Agent | ~15 |\n| 2 | Architecture & Standards | ~25 |\n| 3 | Testing | ~12 |\n| 4 | Backend & API | ~10 |\n| 5 | Frontend & UI | ~6 |\n| 6 | Business & Operations | ~12 |\n| 7 | Security & Compliance | ~6 |\n| 8 | Content & Design | ~7 |\n| 9 | File & System | ~3 |\n| 10 | Learning & Research | ~3 |\n| 11 | Project Generation | ~3 |\n| 12 | Text Processing | ~3 |\n\n### Rating & Safety System\n\nThe awesome-reasonix project includes:\n- **Validation pipeline**: Automated checks on PRs validate format and safety. Pass → published immediately. Fail → comment explains why.\n- **Rating system**: `ratings.json` tracks community ratings. Low-rated skills hidden automatically.\n- **Quarantine**: `quarantine.json` for flagged skills.\n- **Security policy**: `SECURITY.md` + `CODE_OF_CONDUCT.md`. Skills run on your machine — read the file, check ratings, test first.\n\n---\n\n## 7. Long-Term Memory: Hindsight-Reasonix\n\n**Repo**: [github.com/houycth/Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix) \n**Stars**: ⭐0 | **Language**: Python | **Dependencies**: `hindsight-client>=0.4.22`, `mcp>=1.0.0`\n\nA **cross-session long-term memory system** adapted for Reasonix's Hook and MCP mechanisms. Uses the Hindsight API backend for vector storage and retrieval.\n\n### Architecture\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│ hindsight_hooks.py ← auto-triggered by Reasonix hooks │\n│ ├─ session-start → health check │\n│ ├─ pre-turn → recall memory → stdout injection → LLM │\n│ └─ stop → read JSONL → retain conversation turn │\n├──────────────────────────────────────────────────────────────┤\n│ hindsight_mcp.py ← optional MCP server for active search │\n│ ├─ hindsight_recall(query) → search memory │\n│ ├─ hindsight_reflect(query) → synthesize │\n│ └─ hindsight_retain(...) → manual save │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### Data Flow\n\n```\nTurn N:\n UserPromptSubmit → pre-turn:\n ① recall(user message) → stdout → Reasonix captures & injects as \n ② LLM sees memory automatically (no manual reading required)\n\nStop → stop:\n ① Get session_id from hook payload\n ② Locate session JSONL → read last user message\n ③ Strip injection block\n ④ retain(original user message + assistant reply) → store in Hindsight\n```\n\n### Key Features\n\n- **Auto-prefetch**: Pre-turn queries memory and injects via stdout. LLM sees it 100% automatically.\n- **Auto-write**: Stop hook ensures every turn is stored (without injected `` content).\n- **Active search**: LLM can use MCP tools `hindsight_recall` for deep retrieval.\n\n### Setup\n\nThree Reasonix hooks must be configured in `~/.reasonix/settings.json`:\n\n```json\n{\n \"hooks\": {\n \"SessionStart\": [{ \"command\": \"python hindsight_hooks.py session-start\", \"timeout\": 15000 }],\n \"UserPromptSubmit\": [{ \"command\": \"python hindsight_hooks.py pre-turn\", \"timeout\": 30000 }],\n \"Stop\": [{ \"command\": \"python hindsight_hooks.py stop\", \"timeout\": 30000 }]\n }\n}\n```\n\n**Note**: Requires patched Reasonix source — specifically `internal/hook/hook.go`, `internal/hook/runner.go`, `internal/control/controller.go`, and `internal/agent/save.go` — to pass `session_id` through the hook payload and strip `` from saved user messages.\n\n---\n\n## 8. Portable Toolkit: Reasonix PortaKit\n\n**Repo**: [github.com/CS-Faith/reasonix-portakit](https://github.com/CS-Faith/reasonix-portakit) \n**Stars**: ⭐8 | **Language**: PowerShell + JavaScript + Batch | **License**: MIT \n**Latest Release**: v1.0.0 — June 8, 2026\n\nMakes Reasonix **truly portable** — USB drive or cloud-sync folder, plug into any Windows machine, and all memories, conversations, skills, and MCP configurations come with you.\n\n### The Problem\n\n| Symptom | Root Cause |\n|---------|------------|\n| Memories vanish | Project memory directory hashed by workspace path; path changes → hash mismatch |\n| Conversation history blank | Session metadata stores old workspace path; mismatch → all filtered out |\n| Skills / MCP broken | `config.json` hardcodes MCP paths with old drive letter (E:\\Reasonix) |\n| Nothing reads from here | Reasonix defaults to `C:\\Users\\...`, never looks at current directory |\n\n### How It Works\n\n```\n双击 启动Reasonix.bat\n │\n ├─ 1. Auto-detect current path (any drive, any folder)\n ├─ 2. Tell Reasonix: \"Your data is here, not in C:\\Users\\...\"\n ├─ 3. SHA1(path) → project hash → create dirs; auto-rename/merge old hash dirs\n ├─ 4. Merge local machine's Reasonix data (newer-only, no overwrites)\n ├─ 5. Patch config.json — old paths → current path\n ├─ 6. Repair all session metadata (strip BOM + fix JSON escaping + align workspace)\n └─ 7. Launch Reasonix\n```\n\n### Files\n\n| File | Size | Purpose |\n|------|------|---------|\n| `启动Reasonix.bat` | 0.6 KB | Entry point |\n| `_patch_config.ps1` | 6 KB | Core engine |\n| `_fix_sessions.js` | 2.7 KB | Session metadata repair |\n| `PortaKit-核心规则.md` | 2.4 KB | Core rules for Reasonix to remember portable setup |\n\n### Usage\n\n1. Ensure Reasonix has been launched at least once (API key configured)\n2. Copy PortaKit files into the Reasonix directory (next to `reasonix-desktop.exe`)\n3. Always launch via `启动Reasonix.bat` — never double-click `reasonix-desktop.exe` directly\n4. (Recommended) Import `PortaKit-核心规则.md` into Reasonix so it learns the portable mechanism\n5. After one bat launch, the entire folder becomes fully portable:\n - USB drive → any Windows PC → double-click bat → everything intact\n - Sync folder (OneDrive, Dropbox, etc.) → new PC sync → double-click bat → local data merged in\n\n---\n\n## 9. Protocol Landscape: MCP / ACP / CDP\n\n### MCP (Model Context Protocol) — ✅ Well Established\n\nMCP is the **dominant extension protocol** in the Reasonix ecosystem. Reasonix is a first-class MCP client with three transport types:\n\n- **`stdio`** (default) — launches a local subprocess; one JSON message per line over stdin/stdout\n- **`http`** (Streamable HTTP) — remote server at a URL; POST with optional SSE stream\n- **`sse`** — legacy 2024-11-05 transport; recognized but deferred (deprecated upstream)\n\nTools surface to the model as `mcp____`; a tool declaring MCP's `readOnlyHint: true` joins parallel dispatch. MCP prompts appear as `/mcp____` slash commands; MCP resources are pulled in via `@:`.\n\n**`.mcp.json` drop-in**: Reasonix reads a project-root `.mcp.json` using Claude Code's exact `mcpServers` schema — an existing Claude Code MCP config works in Reasonix unchanged.\n\n**`/mcp`** — Lists connected servers and what each exposes.\n\n| Integration | Type | MCP Tools |\n|-------------|------|-----------|\n| reasonix-mcp-server | Claude Code ↔ Reasonix | `reasonix_run`, `reasonix_doctor` |\n| reasonix-bridge | Codex ↔ DeepSeek | `execute_task`, `plan_task`, `orchestrate_task`, etc. |\n| opencode-reasonix-mcp | OpenCode ↔ Reasonix | Task dispatch |\n| Hindsight-Reasonix (MCP mode) | Memory retrieval | `hindsight_recall`, `hindsight_reflect`, `hindsight_retain` |\n| codegraph (built-in) | Semantic code index | Daemon process with MCP interface |\n\nReasonix has **native MCP support** built into its TOML config:\n\n```toml\n[[plugins]]\nname = \"hindsight\"\ncommand = \"python\"\nargs = [\"/path/to/hindsight_mcp.py\"]\n\n# Remote MCP over Streamable HTTP\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n```\n\nThe reference MCP stdio plugin (`cmd/reasonix-plugin-example`) ships as a runnable example with `echo`, `wordcount`, a `review` prompt, and a `style-guide` resource.\n\n### ACP (Agent Communication Protocol) — ✅ Implemented (v1.6.0+)\n\n**Confirmed in the v1.6.0 changelog** (Jun 10, 2026):\n\n```\nfix(cli): disable codegraph in ACP session config test (#3663)\n```\n\nThis confirms Reasonix has a native **ACP (Agent Communication Protocol)** implementation. ACP sessions run with codegraph indexing disabled to avoid daemon interference, which means ACP is a distinct session mode with its own constraints.\n\nOther ACP-adjacent projects and patterns in the ecosystem:\n\n- **[collab-cli](https://github.com/Staryinsang0910-star/collab-cli)** (⭐7) — Universal collaboration protocol + CLI for multi-agent LLM teams. Supports Claude Code, Reasonix, Codex, WorkBuddy, and Cursor as peers in a shared agent mesh. Defines how heterogeneous agents discover each other, delegate work, share context, and synchronize results.\n- **reasonix-bridge** — The \"Scientist Team\" pattern defines role-based inter-agent communication (Dijkstra ↔ Hopper ↔ Turing, etc.) with interface contract extraction between parallel agents.\n- **council skill** (awesome-reasonix) — Multi-agent discussion and decision-making pattern.\n- **Two-model collaboration** (Reasonix native) — The Coordinator runs an executor and planner in separate cache-stable sessions, handing off structured plans between them — a form of intra-tool agent communication.\n\n### CDP (Context Data Protocol) — 🔶 Emerging\n\nNo project explicitly branded \"CDP\" was found. The closest implementations:\n\n- **[Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix)** — Provides cross-session context persistence via MCP tools (`hindsight_recall`, `hindsight_retain`, `hindsight_reflect`). Functions as a context data layer that survives across sessions.\n- **Reasonix's built-in memory** (`.reasonix/memory.md`) — Persistent across sessions, loaded at startup.\n- **Reasonix's semantic index** — Codebase-wide context for faster file discovery.\n\n---\n\n## 10. Use Cases & Workflows\n\n### 10.1 Low-Cost AI Coding (Daily Development)\n\n**Stack**: Reasonix core (DeepSeek V4 Flash default)\n\nThe primary use case. Point Reasonix at a codebase, describe what you want, and it reads files, writes code, runs commands, and iterates. Costs $0.03–$0.15 per typical session thanks to 99.8% prefix cache hits.\n\n**Best for**: Feature development, bug fixing, refactoring, code review, test generation.\n\n```bash\ncd your-project\nreasonix chat\n> Add input validation to the signup form. Email must be valid, password minimum 8 chars.\n> Also add a confirm password field that must match.\n```\n\n### 10.2 Hybrid Claude Code + DeepSeek Workflow\n\n**Stack**: Claude Code + reasonix-mcp-server + Reasonix (DeepSeek)\n\nUse Claude Code for high-level reasoning, architecture design, and complex orchestration. Route the heavy file-editing work to Reasonix/DeepSeek via MCP to save ~80% on token costs.\n\n**Best for**: Teams already using Claude Code who want to reduce API spend without sacrificing quality.\n\n```\nClaude Code (orchestrator)\n → reasonix_run(\"refactor auth module to use JWT\")\n → Reasonix/DeepSeek (executor, $0.05)\n → Claude reviews the diff\n → reasonix_run(\"add tests for the new JWT module\")\n```\n\n### 10.3 Multi-Agent Parallel Development\n\n**Stack**: Codex + reasonix-bridge + Scientist Team\n\nCodex delegates complex multi-module tasks to reasonix-bridge, which automatically decomposes the work and assigns parallel sub-agents (Dijkstra for backend, Hopper for frontend, Turing for database, etc.).\n\n**Best for**: Full-stack feature development, multi-module refactors, greenfield project scaffolding.\n\n```\nCodex → execute_task(\"Build a blog system with backend API, frontend UI, and database\")\n → Bridge auto-detects complexity → multi-agent mode\n ├─ Dijkstra (backend): API routes, controllers, services\n ├─ Hopper (frontend): React components, pages, routing\n └─ Turing (database): schema, migrations, queries\n → Parallel execution, interface contracts extracted\n → All results merged\n```\n\n### 10.4 CI/CD Automation\n\n**Stack**: `reasonix run \"task\"` (non-interactive, piped input)\n\nThe `run` mode is designed for automation. It accepts piped input, runs non-interactively, and exits with appropriate status codes.\n\n**Best for**: Automated code review, test generation, documentation updates, security scanning.\n\n```bash\n# In CI pipeline:\ngit diff HEAD~1 | reasonix run \"review these changes for security issues\"\nreasonix run \"generate documentation for the ./api directory\"\nreasonix run \"update CHANGELOG.md based on git log since last tag\"\n```\n\n### 10.5 Cross-Session Persistent Memory\n\n**Stack**: Hindsight-Reasonix (hooks + MCP)\n\nEvery conversation turn is automatically recalled (pre-turn) and retained (stop). Memory persists across sessions without manual management. The optional MCP server enables active search.\n\n**Best for**: Long-running projects where institutional knowledge must accumulate over weeks/months; onboarding new agents to existing codebases.\n\n```\nSession 1: \"Set up the project structure\"\n → Hindsight retains: architecture decisions, tech stack, conventions\n\nSession 2 (next day): \"Add the payment module\"\n → Hindsight recalls previous session's context\n → LLM sees it automatically as \n → No need to re-explain the project structure\n```\n\n### 10.6 Portable Development Environment\n\n**Stack**: Reasonix PortaKit + USB drive / cloud sync\n\nCarry your entire Reasonix environment (memories, skills, MCP config, conversations) on a USB drive. Plug into any Windows machine, double-click the bat file, and everything is exactly where you left it.\n\n**Best for**: Developers who work across multiple machines (work PC, home PC, laptop); freelancers; educators.\n\n### 10.7 Community Skill Extensions\n\n**Stack**: awesome-reasonix skills\n\nExtend Reasonix with 80+ community-contributed skills covering everything from file organization to multi-agent councils. New skills can be installed in seconds.\n\n**Best for**: Customizing Reasonix for specific workflows, languages, or domains.\n\n```bash\n# Install\ncurl -o .reasonix/skills/deep-research.md https://raw.githubusercontent.com/.../deep-research.md\n# Use\n> /skill deep-research \"Compare the top 5 Rust web frameworks\"\n```\n\n### 10.8 Multi-Agent Collaboration Protocol\n\n**Stack**: collab-cli\n\nUse multiple different agent systems (Claude Code + Reasonix + Codex + Cursor) in a coordinated mesh, each playing to its strengths.\n\n**Best for**: Teams using heterogeneous agent tooling; complex pipelines requiring specialized agents.\n\n---\n\n## 11. Appendix: Quick-Reference Links\n\n### Core\n\n| Resource | URL |\n|----------|-----|\n| Reasonix Main Repo (v1.0 Go) | [github.com/esengine/DeepSeek-Reasonix](https://github.com/esengine/deepseek-reasonix) |\n| Official Website | [reasonix.homes](https://reasonix.homes) |\n| DeepSeek Official Docs | [api-docs.deepseek.com/.../reasonix](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix) |\n| npm Package | [npmjs.com/package/reasonix](https://www.npmjs.com/package/reasonix) |\n| brew Formula | `brew install esengine/reasonix/reasonix` |\n| Architecture Docs | [esengine.github.io/DeepSeek-Reasonix](https://esengine.github.io/DeepSeek-Reasonix/) |\n\n### Official Docs (in repo)\n\n| Document | URL |\n|----------|-----|\n| GUIDE.md (Configuration & Usage) | [github.com/.../docs/GUIDE.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md) |\n| GUIDE.zh-CN.md (简体中文指南) | [github.com/.../docs/GUIDE.zh-CN.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.zh-CN.md) |\n| SPEC.md (Engineering Contract) | [github.com/.../docs/SPEC.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SPEC.md) |\n| CHECKPOINTS.md (Rewind Design) | [github.com/.../docs/CHECKPOINTS.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/CHECKPOINTS.md) |\n| MIGRATING.md (0.x → 1.0) | [github.com/.../docs/MIGRATING.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/MIGRATING.md) |\n| REASONIX.md (Project Memory) | [github.com/.../REASONIX.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/REASONIX.md) |\n| SESSION_REFERENCE_ARCHITECTURE.md | [github.com/.../docs/SESSION_REFERENCE_ARCHITECTURE.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SESSION_REFERENCE_ARCHITECTURE.md) |\n\n### Community\n\n| Resource | URL |\n|----------|-----|\n| Discord (bilingual) | [discord.gg/XF78rEME2D](https://discord.gg/XF78rEME2D) |\n| GitHub Issues | [github.com/.../issues](https://github.com/esengine/deepseek-reasonix/issues) (266 open) |\n| GitHub Discussions | [github.com/.../discussions](https://github.com/esengine/deepseek-reasonix/discussions) |\n| Pull Requests | [github.com/.../pulls](https://github.com/esengine/deepseek-reasonix/pulls) (48 open) |\n\n### MCP Bridges\n\n| Resource | URL |\n|----------|-----|\n| reasonix-mcp-server (Claude Code ←→ Reasonix) | [github.com/Belveth02/reasonix-mcp-server](https://github.com/Belveth02/reasonix-mcp-server) |\n| reasonix-bridge (Codex ←→ DeepSeek) | [github.com/OwlCodeTech/reasonix-bridge](https://github.com/OwlCodeTech/reasonix-bridge) |\n| opencode-reasonix-mcp | [github.com/StarWHAT-BUG/opencode-reasonix-mcp](https://github.com/StarWHAT-BUG/opencode-reasonix-mcp) |\n| collab-cli (Universal Protocol) | [github.com/Staryinsang0910-star/collab-cli](https://github.com/Staryinsang0910-star/collab-cli) |\n\n### Skills & Community\n\n| Resource | URL |\n|----------|-----|\n| awesome-reasonix (Hub) | [github.com/hikari-2424/awesome-reasonix](https://github.com/hikari-2424/awesome-reasonix) |\n| Skills Site (Browse) | [hikari-2424.github.io/awesome-reasonix-site](https://hikari-2424.github.io/awesome-reasonix-site/) |\n| Full Registry JSON | [github.com/.../registry.json](https://github.com/hikari-2424/awesome-reasonix/blob/main/registry.json) |\n\n### Memory & Portability\n\n| Resource | URL |\n|----------|-----|\n| Hindsight-Reasonix (Memory) | [github.com/houycth/Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix) |\n| Reasonix PortaKit | [github.com/CS-Faith/reasonix-portakit](https://github.com/CS-Faith/reasonix-portakit) |\n\n### Guides & Comparisons\n\n| Resource | URL |\n|----------|-----|\n| Complete Guide (aimadetools) | [aimadetools.com/blog/reasonix-complete-guide](https://www.aimadetools.com/blog/reasonix-complete-guide) |\n| Setup Guide | [aimadetools.com/blog/how-to-use-reasonix](https://www.aimadetools.com/blog/how-to-use-reasonix) |\n| Reasonix vs Claude Code | [aimadetools.com/...](https://www.aimadetools.com/blog/reasonix-vs-claude-code) |\n| Reasonix vs Aider | [aimadetools.com/...](https://www.aimadetools.com/blog/reasonix-vs-aider-for-deepseek) |\n\n### Distribution\n\n| Channel | Details |\n|---------|---------|\n| npm | `npm i -g reasonix` (pulls prebuilt Go binary) |\n| Homebrew | `brew install esengine/reasonix/reasonix` |\n| Prebuilt Archives | 6 targets: darwin/linux/windows × amd64/arm64 + SHA256SUMS |\n| Debian | `.deb` package built on release |\n| Source | `make build` (→ `bin/reasonix`) / `make cross` (→ `dist/`) |\n| Windows Signing | [SignPath Foundation](https://signpath.io/) (free certificate) |\n\n### Contributors & Acknowledgments\n\nTop contributors (alphabetical, by commit count + code volume): **ctharvey**, **dimasd-angga**, **Evan-Pycraft**, **ForeverYoungPp**, **GTC2080**, **kabaka9527**, **lisniuse**, **wade19990814-hue**, **wviana**.\n\nLogo by **Bernardxu123**. Community promotion on XiaoHongShu (小红书) by **AIGC Link**.\n\n### Donations\n\nReasonix is MIT-licensed and free. Donations stay \"a coffee, not a contract\" — they don't buy feature priority or change issue triage. [PayPal: paypal.me/yuhuahui](https://paypal.me/yuhuahui) · WeChat Pay (QR in README).\n\n---\n\n> **Document**: Updated Jun 2026 from primary source `esengine/DeepSeek-Reasonix` (main-v2, v1.10.0, Jun 2026), community GitHub repos, DeepSeek API docs, npm, aimadetools, reasonix.homes. \n> **Note**: The \"Reasonix\" name also refers to [reasonixos.com](https://reasonixos.com/about) — an \"Integrated Intelligence Company\" (AI-powered enterprise decision systems / AI Growth OS for marketing). This document covers only the DeepSeek-native coding agent ecosystem, not the enterprise platform.\n"}, "7838d7c041c4f204c131d3de2c18de0cfda03ecc": {"t": 1785750398.1355286, "n": 53174, "h": "f9f143006e199a25", "c": "# Reasonix + DeepSeek Ecosystem: Complete Reference (2026)\n\n> A comprehensive survey of integrations, plugins, tools, skills, MCP bridges, protocols, and use cases in the Reasonix ecosystem as of mid-2026.\n\n---\n\n## Table of Contents\n\n1. [Overview: What is Reasonix?](#1-overview-what-is-reasonix)\n2. [Core Architecture & Cost Model](#2-core-architecture--cost-model)\n3. [Official Integration (DeepSeek Docs)](#3-official-integration-deepseek-docs)\n4. [Desktop Application v1.6.0](#4-desktop-application-v150)\n - 4.1 [Key Desktop Features](#key-desktop-features)\n - 4.2 [Bot Gateway (Feishu / Weixin / QQ)](#bot-gateway-v150)\n - 4.3 [Desktop vs CLI](#desktop-vs-cli)\n5. [MCP Bridges & Cross-Platform Integrations](#5-mcp-bridges--cross-platform-integrations)\n - 5.1 [reasonix-mcp-server (Claude Code ↔ Reasonix)](#51-reasonix-mcp-server-claude-code--reasonix)\n - 5.2 [reasonix-bridge (Codex ↔ DeepSeek, Multi-Agent)](#52-reasonix-bridge-codex--deepseek-multi-agent)\n - 5.3 [opencode-reasonix-mcp (OpenCode Bridge)](#53-opencode-reasonix-mcp-opencode-bridge)\n - 5.4 [collab-cli (Universal Multi-Agent Protocol)](#54-collab-cli-universal-multi-agent-protocol)\n6. [Community Skills Hub (awesome-reasonix)](#6-community-skills-hub-awesome-reasonix)\n - 6.1 [Full Skill Registry](#61-full-skill-registry)\n - 6.2 [Skill Categories](#62-skill-categories)\n7. [Long-Term Memory: Hindsight-Reasonix](#7-long-term-memory-hindsight-reasonix)\n8. [Portable Toolkit: Reasonix PortaKit](#8-portable-toolkit-reasonix-portakit)\n9. [Protocol Landscape: MCP / ACP / CDP](#9-protocol-landscape-mcp--acp--cdp)\n10. [Use Cases & Workflows](#10-use-cases--workflows)\n11. [Appendix: Quick-Reference Links](#11-appendix-quick-reference-links)\n\n---\n\n## 1. Overview: What is Reasonix?\n\n**Reasonix** is an open-source (MIT), terminal-based AI coding agent built specifically for **DeepSeek models**. It is listed on DeepSeek's official API documentation as a recommended integration, giving it a degree of first-party endorsement that few third-party tools have.\n\n| Attribute | Value |\n|-----------|-------|\n| **License** | MIT (fully open source) |\n| **Language** | Go (1.0, `main-v2` branch — current); TypeScript (0.x, `v1` branch — legacy/maintenance) |\n| **GitHub Stars** | 20,700+ |\n| **Forks** | 1,200+ |\n| **Releases** | 46 (v1.6.0 — Jun 10, 2026) |\n| **Commits** | 777+ |\n| **Default Model** | DeepSeek V4 Flash (via config TOML); also presets for DeepSeek V4 Pro, MiMo-v2.5-pro, MiMo-v2.5 |\n| **Upgrade Model** | DeepSeek V4 Pro or MiMo-v2.5-pro (per-model via `/model` or `/preset max`) |\n| **Platforms** | macOS (brew + prebuilt), Linux (.deb + prebuilt), Windows (prebuilt, code-signed via SignPath) |\n| **Config Format** | TOML (`reasonix.toml` — flag > project > user > defaults resolution) |\n| **Node Requirement** | None for v1.0 Go binary; Node ≥ 22 only for npm install shim or legacy 0.x |\n| **Alias** | `dsnix` |\n| **Install** | `npm i -g reasonix`, `brew install esengine/reasonix/reasonix`, prebuilt archives (6 targets), `make build`/`make cross` from source |\n| **Official Docs** | [DeepSeek Agent Integrations](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix); [Guide (GUIDE.md)](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md); [Spec (SPEC.md)](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SPEC.md) |\n\n### Three Operating Modes\n\n| Mode | Command | Description |\n|------|---------|-------------|\n| **chat** (default) | `reasonix chat` | Interactive conversation with full tool access. Run `/init` to generate AGENTS.md project memory. Use for actual development. |\n| **run** (one-shot) | `reasonix run \"task\"` | Executes a single task and exits. Accepts piped input and `--model` flag. Ideal for CI/CD, scripting, automation pipelines. |\n| **code** | `reasonix chat` | Legacy alias preserved from 0.x; routes to `chat` in v1.0+. |\n\n### Model Switching\n\n- **`/model`** — Interactively switch the active model mid-session; selection persists to user config.\n- **`/preset max`** — Switches the entire session to a higher-tier model (e.g. DeepSeek V4 Pro).\n- **Two-model collaboration** — Set `[agent].planner_model` in `reasonix.toml` to run a separate low-frequency planner (e.g. MiMo-v2.5-pro) alongside the executor, each in its own cache-stable session.\n- **Subagent models** — `subagent_model` and `subagent_models` config keys let specific skills (like `review`, `security_review`) run on different models.\n- **Multi-provider presets** — DeepSeek (flash/pro) and MiMo (mimo-v2.5-pro / mimo-v2.5) ship as config presets; any OpenAI-compatible endpoint is a config entry.\n\n### Built-in Features\n\n- **Plan mode** — Auto-plan (`/auto-plan on`) or manual (`/plan`); two-model planner with read-only research tools before executor acts.\n- **Checkpoints & Rewind** — File-snapshot-based undo (`Esc-Esc` or `/rewind`); restore code, conversation, or both; fork-from-here; works without touching `.git`.\n- **Goal mode** — `/goal ` starts an autonomous, session-scoped active goal loop with blocked-state audit; desktop-only.\n- **Memory** — Hierarchical `REASONIX.md` (committed) + `REASONIX.local.md` (personal, gitignored) + user-global `~/.config/reasonix/REASONIX.md` + ancestor dirs; `AGENTS.md` accepted as fallback.\n- **`remember` tool** — Saves durable facts to per-project auto-memory (frontmatter files + `MEMORY.md` index), loaded into prefix on next session.\n- **`#` quick-add** — Type `#` in chat to quick-add a line to the project's `REASONIX.md`.\n- **`@path` imports** — A line containing `@path/to/another/file.md` injects that file's content into the memory prefix.\n- **Session branching** — `/tree` shows saved conversation branches; `/branch [name]` forks current tip; `/branch [name]` forks from a checkpointed turn; `/switch ` loads another branch.\n- **Subagent transcripts** — `/task` spawns sub-agents; continue/resume past transcripts in v1.6.0+.\n- **MCP/Plugins** — First-class MCP client: stdio, HTTP (Streamable), and SSE transports; `.mcp.json` drop-in from Claude Code works unchanged.\n- **Skills** — Reusable prompt templates in `.reasonix/skills/*.md`; frontmatter supports `runAs: subagent` and `allowed-tools` for isolated execution; `/skill new my-skill` scaffolds one.\n- **`read_skill` tool** — Load inline skills in plan mode without execution (v1.6.0+).\n- **Slash commands** — 20+ built-ins (`/compact`, `/new`, `/clear`, `/rewind`, `/tree`, `/branch`, `/switch`, `/todo`, `/model`, `/mcp`, `/skills`, `/hooks`, `/memory`, `/output-style`, `/sandbox`, `/language`, `/auto-plan`, `/help`); custom commands in `.reasonix/commands/*.md`.\n- **Chat references (`@`)** — `@path/to/file` injects file contents (or directory listing) as tagged context; `@:` injects MCP resources. Autocomplete on `/` or `@`.\n- **Web search** — Mojeek, SearXNG, or Metaso backends.\n- **Transcript replay & events** — Every event hits disk; `reasonix replay`, `reasonix events`, `reasonix stats`.\n- **Bot gateway** — Feishu/Weixin/QQ adapters (v1.6.0+).\n- **PDF extraction** — Extract text from PDF attachments (v1.6.0+).\n- **Sandbox** — macOS Seatbelt enforcement for Bash (jailed to workspace + temp + toolchain caches, network opt-in); file-writers confined to `workspace_root` with symlink/`..` traversal prevention; Linux bubblewrap/landlock on roadmap.\n- **Permisssions** — `deny > ask > allow > fallback` per-tool-call gating; tool-approval postures: ask, auto, yolo.\n- **Context compaction** — Low-frequency cache-reset point when approaching `context_window` limit; older middle of session summarized; dropped originals archived as JSONL.\n- **Codegraph** — Semantic code index for faster file discovery (daemon process with MCP interface).\n\n---\n\n## 2. Core Architecture & Cost Model\n\n### Cache-First Philosophy\n\nReasonix is engineered around **DeepSeek's byte-stable prefix cache**. Every LLM API provider offers prefix caching, but DeepSeek's is particularly aggressive — if the start of a prompt matches a cached prefix exactly, the input token cost drops to ~1/5th the normal rate (per official reasonix.homes: ~94% cache hit, ~2.5× cost reduction). Any change invalidates the cache.\n\nGeneric tools restructure prompts for correctness. Reasonix structures them for **cache stability**:\n\n1. **Fixed prefix ordering** — System prompt, project context, and memory are always in the same position. New information is appended, never inserted.\n2. **Incremental context growth** — Files added to context go at the end. Nothing before them shifts.\n3. **Stable tool call formatting** — Tool results are formatted identically every time.\n\n### Real-World Cost Numbers\n\n| Session Type | Input Tokens | Cache Hit Rate | Effective Cost | Without Cache |\n|:-------------|:-------------|:---------------|:---------------|:--------------|\n| Quick fix (5 min) | 50K | 99.5% | ~$0.01 | ~$0.04 |\n| Feature (30 min) | 500K | 99.8% | ~$0.05 | ~$0.24 |\n| Refactor (2 hrs) | 3M | 99.9% | ~$0.15 | ~$1.31 |\n| Full day coding | 20M | 99.8% | ~$0.85 | ~$4.12 |\n\n**Headline stat**: 435M input tokens processed with 99.82% cache hits, costing approximately **$12** instead of the **$61** it would have cost without cache optimization. (Real production data, not a benchmark.)\n\n### Configuration\n\n**Resolution order**: `flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults`.\n\nSecrets come from the environment via `api_key_env` and are **never stored in config files**. A `.env` in the working directory is loaded if present. Step-limit preferences belong in user config; project `reasonix.toml` should override them only when the repository needs shared runtime bounds.\n\n**Minimal `reasonix.toml`** — one provider and a default model is enough to start:\n\n```toml\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n```\n\n**Full example**:\n\n```toml\ndefault_model = \"deepseek-flash\" # executor; set [agent].planner_model to add a planner\n\n[agent]\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\n# planner_model = \"mimo-pro\" # optional low-frequency planner\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" }\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n\n[tools]\nenabled = [] # omit/empty = all built-ins\nbash_timeout_seconds = 120\n\n[skills]\npaths = [] # extra custom skill roots\nexcluded_paths = [] # hide convention roots without deleting\ndisabled_skills = [] # hidden from prompt, slash invocation, and skill tools\n\n[permissions]\nmode = \"ask\" # writer fallback: ask|allow|deny\ndeny = [\"Bash(rm -rf*)\", \"Bash(git push*)\"] # hard-blocked in every mode\nallow = [\"Bash(go test:*)\"] # never prompted\n\n[sandbox]\n# workspace_root = \"\" # file-writers confined here; empty = current dir\n# allow_write = [\"/tmp\"]\n\n[[plugins]]\nname = \"github\"\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-github\"]\n\n# Remote MCP server over Streamable HTTP\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n```\n\n**`.mcp.json` drop-in support**: Reasonix also reads a project-root `.mcp.json` using Claude Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`, `${VAR}` expansion). Both sources are merged; on a name collision `reasonix.toml` wins.\n\n```json\n{\n \"mcpServers\": {\n \"filesystem\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/path\"] },\n \"stripe\": { \"type\": \"http\", \"url\": \"https://mcp.stripe.com\", \"headers\": { \"Authorization\": \"Bearer ${STRIPE_KEY}\" } }\n }\n}\n```\n\n---\n\n## 3. Official Integration (DeepSeek Docs)\n\nDeepSeek's official API documentation includes Reasonix as a **recommended agent integration** alongside Claude Code, GitHub Copilot, WorkBuddy, OpenCode, Kilo Code, and others.\n\n**Installation**:\n\n```bash\n# Any OS — pulls the prebuilt Go native binary\nnpm i -g reasonix\n\n# macOS\nbrew install esengine/reasonix/reasonix\n\n# Prebuilt archives for all platforms (darwin|linux|windows × amd64|arm64)\n# Available on every GitHub release, with SHA256SUMS\n\n# Build from source\ngit clone https://github.com/esengine/deepseek-reasonix.git\ncd deepseek-reasonix\nmake build # -> bin/reasonix(.exe)\nmake cross # -> dist/ (6 targets)\n```\n\nWindows builds are **code-signed** with a free certificate provided by the [SignPath Foundation](https://signpath.io/). Linux `.deb` packages are built on release.\n\n**Quick-start** (from [deepseek.com](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix)):\n\n```bash\n# 1. Get a DeepSeek API Key from platform.deepseek.com\n# 2. Run the config wizard:\nreasonix setup # writes ./reasonix.toml\n\n# 3. Start chatting:\nexport DEEPSEEK_API_KEY=sk-... # or put it in .env\nreasonix chat # then run /init to generate AGENTS.md\n\n# Or one-shot:\nreasonix run \"implement the TODOs in main.go\"\nreasonix run --model mimo-pro \"add unit tests for this function\"\necho \"explain this code\" | reasonix run\n```\n\nNo Node.js required for v1.0+ (single Go binary). First launch with `reasonix setup` writes a minimal `reasonix.toml`. Secrets stay in environment variables (`api_key_env`), never in config files.\n\n---\n\n## 4. Desktop Application v1.6.0\n\n**Built into the main repo**: [desktop/](https://github.com/esengine/deepseek-reasonix/tree/main-v2/desktop) directory in `esengine/DeepSeek-Reasonix`. \n**Stack**: Wails + React 19 + TypeScript 6 | **Latest**: v1.6.0 (Jun 10, 2026)\n\nThe desktop app is a **visual companion to the CLI**, not a Cursor replacement. It provides a GUI wrapper around the same Go controller — the terminal TUI, desktop webview, and HTTP/SSE server all drive the same `control.Controller`, so every feature surfaces identically across frontends.\n\n### Key Desktop Features\n\n- **Themed workspace UI** — Dark and light themes with a full sidebar: session list, file tree, MCP settings panel, skills browser, and memory view. Screenshots at `docs/desktop-theme-dark.jpg` and `docs/desktop-theme-light.jpg`.\n- **Collaboration-mode picker** — Three choices: **normal** (standard chat), **plan** (`/plan`), and **goal** (`/goal`).\n- **Goal mode** (`/goal `) — Starts an autonomous, session-scoped active goal loop. The controller prepends goal context to user turns (outside the cache-stable system prompt) and keeps issuing continuation turns until the model reports completion, repeats the same blocked state three times, or the safety limit is reached. Blocked-state matching is normalized for casing, whitespace, and punctuation. `/goal clear` removes the active goal.\n- **Tool-approval postures** — **ask** (each writer/bash call prompts), **auto** (writer fallback auto-allowed, explicit ask/deny rules still apply), **yolo** (approval prompts auto-allowed unless denied). These are separate from the collaboration-mode picker.\n- **`read_skill` tool** — Load inline skills in plan mode without execution (v1.6.0+).\n- **Session workspace isolation** — Desktop sessions are isolated per workspace directory; switching workspaces loads the correct session history.\n- **MCP settings panel** — View connected servers, connection status, retry failures, inspect each server's tools/prompts/resources. Collapses noisy startup errors into a summary.\n- **Reveal-in-file-manager** — Right-click any file in the workspace tree to reveal it in Finder/Explorer.\n- **Image paste** — Paste clipboard images directly into the chat composer.\n- **Model selector dropdown** — Switch models interactively; scroll support and tooltips for truncated names; preserves curated provider models on refresh.\n- **Rewind (hover)** — Each user message in the transcript has a hover rewind control → menu: rewind code / rewind conversation / both / fork-from-here.\n- **Subagent transcript continue/resume** — Continue past subagent transcripts from where they left off (v1.6.0+).\n- **PDF attachment text extraction** — Extract text from PDF attachments dropped into the chat (v1.6.0+).\n\n### Bot Gateway (v1.6.0+)\n\nReasonix ships a **bot gateway** with adapters for:\n- **Feishu (飞书)** — Chinese enterprise messaging\n- **Weixin (微信)** — China's dominant messaging app\n- **QQ** — Tencent's instant messaging platform\n\nThe gateway receives messages from these platforms, routes them through the Reasonix agent, and sends responses back. Each adapter handles authentication (Feishu webhook tokens, Weixin account/login). This turns Reasonix into a deployable AI coding assistant accessible from chat platforms — useful for teams that want a shared coding agent reachable from their existing messaging tools.\n\n### Desktop vs CLI\n\n| Feature | CLI (TUI) | Desktop |\n|---------|-----------|---------|\n| Chat | Full transcript, scrolling (Ctrl+Home/End in v1.6.0) | Visual transcript with hover controls |\n| Plan mode | `/plan` | Collaboration-mode picker |\n| Goal mode | Not available | `/goal` with blocked-state audit |\n| Checkpoints/Rewind | `Esc-Esc` / `/rewind` picker | Hover rewind on each message |\n| MCP management | `/mcp` slash command | Full settings panel with retry |\n| Skills | `/skills` | Visual browser |\n| File tree | `/tree` | Sidebar with reveal-in-file-manager |\n| Image paste | Via file path | Clipboard paste |\n| Bot gateway | Available via serve mode | Included |\n\n---\n\n## 5. MCP Bridges & Cross-Platform Integrations\n\n### 5.1 reasonix-mcp-server (Claude Code ↔ Reasonix)\n\n**Repo**: [github.com/Belveth02/reasonix-mcp-server](https://github.com/Belveth02/reasonix-mcp-server) \n**Stars**: ⭐2 | **Language**: JavaScript | **License**: MIT\n\nThis MCP server lets **Claude Code call Reasonix as a DeepSeek sub-agent** through the Model Context Protocol. The core idea: use Claude for high-level reasoning and orchestration, but delegate the actual file-editing work to Reasonix (which costs ~1/5 as much thanks to prefix caching).\n\n#### Tools Exposed\n\n| Tool | Parameters | Description |\n|------|------------|-------------|\n| `reasonix_run` | `task` (string, required), `model` (string, optional), `effort` (enum: low/medium/high/max), `budget` (number, optional), `timeout` (number, default 300000ms) | Execute a coding task via Reasonix |\n| `reasonix_doctor` | `json` (boolean, optional) | Run health check: API key, network, config |\n\n#### Installation\n\n```bash\nnpm install -g reasonix\ngit clone https://github.com/Belveth/reasonix-mcp-server.git\ncd reasonix-mcp-server\nnpm install\n```\n\nRegister with Claude Code:\n\n```bash\nclaude mcp add reasonix --scope user -- \\\n --command node \\\n --args \"/absolute/path/to/server.js\"\n```\n\nOr manually edit `~/.claude.json`:\n\n```json\n\"mcpServers\": {\n \"reasonix\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\"C:\\\\Users\\\\xxx\\\\.claude\\\\tools\\\\reasonix-mcp-server\\\\server.js\"],\n \"env\": {}\n }\n}\n```\n\n#### Cost Comparison\n\nIn real-world testing: 435M tokens processed at ~$12 via Reasonix/DeepSeek. Equivalent work on Claude: ~$61. That's an **~80% cost reduction** by routing the heavy file-editing work through Reasonix's cache-optimized DeepSeek pipeline.\n\n---\n\n### 5.2 reasonix-bridge (Codex ↔ DeepSeek, Multi-Agent)\n\n**Repo**: [github.com/OwlCodeTech/reasonix-bridge](https://github.com/OwlCodeTech/reasonix-bridge) \n**Stars**: ⭐6 | **Language**: JavaScript (~1500 lines, single file) | **License**: MIT\n\nA **multi-agent parallel execution engine** that lets **Codex delegate tasks to DeepSeek via MCP**. Supports two-phase execution, multi-agent parallelism, incremental mode, and dynamic budget allocation.\n\n#### Architecture\n\n```\nCodex (MCP Client)\n │ stdio pipe\n ▼\nindex.js (MCP Server)\n │\n ├─ 6 MCP tools\n ├─ 8 local sandbox tools (read_file / write_file / run_command / ...)\n └─ DeepSeek API (streaming)\n ├─ Planning mode (no tools, produces plan)\n └─ Execution mode (8 tools, agent loop)\n```\n\n#### Tools Exposed\n\n| Tool | Purpose | Recommended Use |\n|------|---------|-----------------|\n| `execute_task` | Auto-detect: single or multi-agent | Daily use |\n| `plan_task` | Generate plan only, no execution | \"Show me the plan first\" |\n| `execute_step` | Execute a specific step | Pair with `plan_task` |\n| `orchestrate_task` | Force multi-agent parallelism | When explicit division of labor is needed |\n| `improve_file` | DeepSeek-targeted fix; Codex retains override | When Codex finds something to fix |\n| `delegate_to_reasonix` | Legacy one-shot execution | Backward compatibility |\n\n#### The \"Scientist Team\" (Role-Based Parallel Agents)\n\nWhen a task requires multi-agent execution, Bridge assigns roles automatically:\n\n| Emoji | Scientist | Role | Domain |\n|:-----|:----------|:-----|:--------|\n| 🖥️ | Dijkstra | Backend Engineer | Servers, APIs, algorithms |\n| 🎨 | Hopper | Frontend Developer | UI, interactions, components |\n| 🗄️ | Turing | Data Engineer | Database, storage |\n| 🔗 | Berners-Lee | Interface Engineer | API communication |\n| 🧪 | Lovelace | Test Engineer | Validation, deployment |\n\n**Sub-agent decomposition**: A single-domain complex task auto-splits:\n```\n🖥️ Dijkstra\n ├─ Dijkstra-A (user auth module)\n ├─ Dijkstra-B (CRUD operations)\n └─ Dijkstra-C (file upload)\n```\n\n#### Two-Phase Execution\n\n1. **Phase 1 (Planning)**: DeepSeek decomposes task → JSON step list (5-10 seconds)\n2. **Phase 2 (Execution)**: Step-by-step or multi-agent parallel (each step has independent context)\n - Previous outputs already on disk, unaffected by subsequent budget limits\n\n**Parallel scheduling**: Independent sub-tasks → `Promise.all` parallel execution. Dependent sub-tasks → wait for dependencies. Interface contracts extracted after each step and passed to subsequent agents.\n\n#### Security Model: 5-Level Command Control\n\n| Mode | Permissions |\n|------|-------------|\n| `off` | No commands, read-only files |\n| `readonly` | `pwd`, `echo`, `which` |\n| `static` | + `git` (read-only), `node --check`, `tsc` |\n| `verify` | + `npm test`, `eslint`, `jest` |\n| `full` | All open + write files |\n\nAdditional security: file sandbox (validates all paths, rejects traversal/symlink escapes), hash-based overwrite prevention, read-only mode auto-disables `edit_file`/`write_file`, forced-write audit logging, `REASONIX_MAX_TOKENS` hard cap.\n\n#### Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `DEEPSEEK_API_KEY` | — | Required |\n| `REASONIX_COMMAND_MODE` | `static` | Command permission level |\n| `WORKSPACE_ROOT` | `cwd` | Sandbox root directory |\n| `DEEPSEEK_MODEL` | `deepseek-chat` | Model identifier |\n| `DEEPSEEK_TEMPERATURE` | `0.2` | Temperature |\n| `REASONIX_MAX_TOKENS` | `64000` | Cumulative token budget |\n| `HTTP_PROXY` | — | Proxy (for non-China API access) |\n| `GITHUB_TOKEN` | — | GitHub API rate-limit boost |\n\n---\n\n### 5.3 opencode-reasonix-mcp (OpenCode Bridge)\n\n**Repo**: [github.com/StarWHAT-BUG/opencode-reasonix-mcp](https://github.com/StarWHAT-BUG/opencode-reasonix-mcp) \n**Stars**: ⭐0 | **Language**: JavaScript\n\nDispatches **stateless sub-tasks from OpenCode to Reasonix CLI** via MCP, saving ~99% of token costs compared to running the same tasks on Claude or GPT, by routing them through DeepSeek's prefix-cached pipeline.\n\n---\n\n### 5.4 collab-cli (Universal Multi-Agent Protocol)\n\n**Repo**: [github.com/Staryinsang0910-star/collab-cli](https://github.com/Staryinsang0910-star/collab-cli) \n**Stars**: ⭐7 | **Language**: TypeScript | **Tags**: `mcp`, `collaboration`, `protocol`, `multi-agent`\n\nA **universal collaboration protocol + CLI** for multi-agent LLM teams. Supports **Claude Code, Reasonix, Codex, WorkBuddy, and Cursor** as peers in a shared agent mesh. This is the closest project found to an **ACP (Agent Communication Protocol)** implementation — it defines how different agent systems discover each other, delegate work, share context, and synchronize results.\n\n---\n\n## 6. Community Skills Hub (awesome-reasonix)\n\n**Repo**: [github.com/hikari-2424/awesome-reasonix](https://github.com/hikari-2424/awesome-reasonix) \n**Stars**: ⭐2 | **Language**: HTML / Markdown | **License**: MIT \n**Site**: [hikari-2424.github.io/awesome-reasonix-site/](https://hikari-2424.github.io/awesome-reasonix-site/)\n\nAn **independent community project** (not affiliated with Reasonix) that collects, rates, and shares community-contributed Skills. Skills are Markdown files dropped into `.reasonix/skills/` and invoked via `/skill `.\n\n**Registry version**: 3 | **Last updated**: 2026-05-30 | **80+ published skills**\n\n### Installation\n\n```bash\n# Single skill\ncurl -o .reasonix/skills/file-organizer.md https://raw.githubusercontent.com/hikari-2424/awesome-reasonix/main/skills/file-organizer.md\n\n# Then inside Reasonix:\n> /skill file-organizer\n```\n\n### 6.1 Full Skill Registry\n\n#### File & System\n\n| Skill | Description |\n|-------|-------------|\n| `file-organizer` | Classify files by extension, find duplicates, tree view, clean empty dirs |\n| `disk-cleaner` | Disk space analysis and cleanup automation |\n\n#### Text Processing\n\n| Skill | Description |\n|-------|-------------|\n| `text-polish` | Polish, translate, summarize, rewrite — 4 output styles (professional/casual/concise/tech-doc) |\n| `data-convert` | Convert data between formats (JSON, CSV, YAML, etc.) |\n| `crosspost` | Cross-post content across platforms |\n\n#### Learning & Research\n\n| Skill | Description |\n|-------|-------------|\n| `learn-topic` | Search → fetch → organize → summarize (subagent mode) |\n| `deep-research` | Deep research agent with iterative retrieval |\n| `data-scraper-agent` | Web scraping agent |\n\n#### System Control\n\n| Skill | Description |\n|-------|-------------|\n| `input-control` | Windows mouse/keyboard automation via PowerShell |\n| `knowledge-ops` | Knowledge base management operations |\n\n#### Development Tools\n\n| Skill | Description |\n|-------|-------------|\n| `git-helper` | Conventional Commits, PR descriptions, history analysis, conflict resolution |\n| `code-review` | 4-dimension review (logic, security, performance, style) with file:line output |\n| `changelog-generator` | Auto-generate CHANGELOG.md from git history, semver inference |\n| `database-migrations` | Database migration patterns and automation |\n| `deployment-patterns` | Deployment strategies and CI/CD patterns |\n| `docker-patterns` | Docker and containerization patterns |\n| `github-ops` | GitHub operations automation |\n\n#### Project Generation\n\n| Skill | Description |\n|-------|-------------|\n| `project-scaffold` | Interactive scaffolding: Python, TypeScript, Go, Rust |\n| `readme-gen` | Auto-generate README.md from project structure analysis |\n| `api-connector-builder` | Build API connectors matching existing integration patterns |\n\n#### API & Integration\n\n| Skill | Description |\n|-------|-------------|\n| `api-design` | REST API design patterns: naming, status codes, pagination, filtering, error responses, versioning |\n| `jira-integration` | Jira issue tracking integration |\n\n#### Content & Design\n\n| Skill | Description |\n|-------|-------------|\n| `article-writing` | Write articles, guides, blog posts, tutorials, newsletters |\n| `brand-voice` | Extract writing style profiles from real content, build reusable brand voice config |\n| `code-tour` | Create step-by-step CodeTour files with real file:line anchors |\n| `dashboard-builder` | Build dashboards and data visualizations |\n| `frontend-slides` | Create HTML presentations from scratch or convert PowerPoint |\n| `content-engine` | Content generation and management engine |\n| `liquid-glass-design` | Modern UI design patterns |\n\n#### AI & Agent\n\n| Skill | Description |\n|-------|-------------|\n| `autonomous-loops` | Autonomous agent loop patterns: from simple pipelines to RFC-driven multi-agent workflows |\n| `blueprint` | Turn one-line objective into multi-phase, multi-agent engineering plan with dependency graph |\n| `council` | Multi-agent discussion and decision-making council |\n| `continuous-learning` | Auto-extract reusable patterns from Claude Code sessions |\n| `continuous-learning-v2` | Instinct-based learning system with confidence scoring |\n| `agentic-engineering` | Agent-driven engineering patterns |\n| `agent-harness-construction` | Build agent harnesses for testing and evaluation |\n| `agent-introspection-debugging` | Debug and introspect agent behavior |\n| `agent-sort` | Agent categorization and sorting |\n| `ai-first-engineering` | AI-first development methodology |\n| `eval-harness` | Formal evaluation framework for AI sessions |\n| `cost-aware-llm-pipeline` | Cost-optimized LLM pipeline patterns |\n| `iterative-retrieval` | Progressive context retrieval pattern |\n\n#### Architecture & Standards\n\n| Skill | Description |\n|-------|-------------|\n| `coding-standards` | Cross-project coding conventions: naming, readability, immutability, review criteria |\n| `backend-patterns` | Backend architecture: API design, database optimization, Node.js/Express/Next.js best practices |\n| `frontend-patterns` | Frontend patterns: React, Next.js, state management, performance |\n| `frontend-design` | UI/UX design patterns |\n| `android-clean-architecture` | Clean Architecture for Android/Kotlin Multiplatform |\n| `compose-multiplatform-patterns` | Compose Multiplatform / Jetpack Compose: state, navigation, theming, testing |\n| `dart-flutter-patterns` | Dart and Flutter patterns |\n| `kotlin-patterns` | Kotlin patterns and best practices |\n| `kotlin-coroutines-flows` | Coroutines and Flow patterns |\n| `kotlin-exposed-patterns` | JetBrains Exposed ORM patterns |\n| `kotlin-ktor-patterns` | Ktor framework patterns |\n| `java-coding-standards` | Java coding standards for Spring Boot |\n| `jpa-patterns` | JPA/Hibernate patterns |\n| `springboot-patterns` | Spring Boot architecture and patterns |\n| `django-patterns` | Django architecture patterns |\n| `django-security` | Django security best practices |\n| `laravel-patterns` | Laravel framework patterns |\n| `laravel-security` | Laravel security practices |\n| `golang-patterns` | Idiomatic Go patterns |\n| `cpp-coding-standards` | C++ Core Guidelines-based standards |\n| `dotnet-patterns` | .NET architecture patterns |\n| `perl-patterns` | Perl patterns and best practices |\n| `rust-patterns` | Rust patterns and best practices |\n| `clickhouse-io` | ClickHouse database patterns and optimization |\n\n#### Testing\n\n| Skill | Description |\n|-------|-------------|\n| `django-tdd` | Django testing with pytest-django, TDD methodology |\n| `django-verification` | Django verification loop: migrations, linting, tests, security |\n| `laravel-tdd` | Laravel testing patterns |\n| `laravel-verification` | Laravel verification loop |\n| `springboot-tdd` | Spring Boot testing patterns |\n| `springboot-verification` | Spring Boot verification loop |\n| `golang-testing` | Go testing: table-driven, subtests, benchmarks, fuzzing |\n| `cpp-testing` | C++ testing with GoogleTest/CTest |\n| `kotlin-testing` | Kotlin testing patterns |\n| `csharp-testing` | C# testing patterns |\n| `e2e-testing` | Playwright E2E testing, Page Object Model, CI/CD |\n| `ai-regression-testing` | Regression testing for AI-assisted development |\n\n#### Business & Operations\n\n| Skill | Description |\n|-------|-------------|\n| `carrier-relationship-management` | Carrier portfolio management, freight rate negotiation, performance tracking |\n| `customer-billing-ops` | Customer billing operations |\n| `customs-trade-compliance` | Customs and trade compliance |\n| `energy-procurement` | Energy procurement strategies |\n| `enterprise-agent-ops` | Enterprise agent operations |\n| `finance-billing-ops` | Finance and billing operations |\n| `inventory-demand-planning` | Inventory and demand planning |\n| `investor-materials` | Investor materials generation |\n| `investor-outreach` | Investor outreach automation |\n| `lead-intelligence` | Lead intelligence and scoring |\n| `logistics-exception-management` | Logistics exception handling |\n| `connections-optimizer` | Network/connections optimization |\n\n#### Security & Compliance\n\n| Skill | Description |\n|-------|-------------|\n| `defi-amm-security` | DeFi AMM security patterns |\n| `llm-trading-agent-security` | LLM trading agent security |\n| `evm-token-decimals` | EVM token decimal handling |\n| `healthcare-phi-compliance` | Healthcare PHI compliance |\n| `hipaa-compliance` | HIPAA compliance patterns |\n| `ecc-tools-cost-audit` | Tool cost auditing for ECC |\n\n### 6.2 Skill Categories\n\n| # | Category | Count |\n|---|----------|-------|\n| 1 | AI & Agent | ~15 |\n| 2 | Architecture & Standards | ~25 |\n| 3 | Testing | ~12 |\n| 4 | Backend & API | ~10 |\n| 5 | Frontend & UI | ~6 |\n| 6 | Business & Operations | ~12 |\n| 7 | Security & Compliance | ~6 |\n| 8 | Content & Design | ~7 |\n| 9 | File & System | ~3 |\n| 10 | Learning & Research | ~3 |\n| 11 | Project Generation | ~3 |\n| 12 | Text Processing | ~3 |\n\n### Rating & Safety System\n\nThe awesome-reasonix project includes:\n- **Validation pipeline**: Automated checks on PRs validate format and safety. Pass → published immediately. Fail → comment explains why.\n- **Rating system**: `ratings.json` tracks community ratings. Low-rated skills hidden automatically.\n- **Quarantine**: `quarantine.json` for flagged skills.\n- **Security policy**: `SECURITY.md` + `CODE_OF_CONDUCT.md`. Skills run on your machine — read the file, check ratings, test first.\n\n---\n\n## 7. Long-Term Memory: Hindsight-Reasonix\n\n**Repo**: [github.com/houycth/Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix) \n**Stars**: ⭐0 | **Language**: Python | **Dependencies**: `hindsight-client>=0.4.22`, `mcp>=1.0.0`\n\nA **cross-session long-term memory system** adapted for Reasonix's Hook and MCP mechanisms. Uses the Hindsight API backend for vector storage and retrieval.\n\n### Architecture\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│ hindsight_hooks.py ← auto-triggered by Reasonix hooks │\n│ ├─ session-start → health check │\n│ ├─ pre-turn → recall memory → stdout injection → LLM │\n│ └─ stop → read JSONL → retain conversation turn │\n├──────────────────────────────────────────────────────────────┤\n│ hindsight_mcp.py ← optional MCP server for active search │\n│ ├─ hindsight_recall(query) → search memory │\n│ ├─ hindsight_reflect(query) → synthesize │\n│ └─ hindsight_retain(...) → manual save │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### Data Flow\n\n```\nTurn N:\n UserPromptSubmit → pre-turn:\n ① recall(user message) → stdout → Reasonix captures & injects as \n ② LLM sees memory automatically (no manual reading required)\n\nStop → stop:\n ① Get session_id from hook payload\n ② Locate session JSONL → read last user message\n ③ Strip injection block\n ④ retain(original user message + assistant reply) → store in Hindsight\n```\n\n### Key Features\n\n- **Auto-prefetch**: Pre-turn queries memory and injects via stdout. LLM sees it 100% automatically.\n- **Auto-write**: Stop hook ensures every turn is stored (without injected `` content).\n- **Active search**: LLM can use MCP tools `hindsight_recall` for deep retrieval.\n\n### Setup\n\nThree Reasonix hooks must be configured in `~/.reasonix/settings.json`:\n\n```json\n{\n \"hooks\": {\n \"SessionStart\": [{ \"command\": \"python hindsight_hooks.py session-start\", \"timeout\": 15000 }],\n \"UserPromptSubmit\": [{ \"command\": \"python hindsight_hooks.py pre-turn\", \"timeout\": 30000 }],\n \"Stop\": [{ \"command\": \"python hindsight_hooks.py stop\", \"timeout\": 30000 }]\n }\n}\n```\n\n**Note**: Requires patched Reasonix source — specifically `internal/hook/hook.go`, `internal/hook/runner.go`, `internal/control/controller.go`, and `internal/agent/save.go` — to pass `session_id` through the hook payload and strip `` from saved user messages.\n\n---\n\n## 8. Portable Toolkit: Reasonix PortaKit\n\n**Repo**: [github.com/CS-Faith/reasonix-portakit](https://github.com/CS-Faith/reasonix-portakit) \n**Stars**: ⭐8 | **Language**: PowerShell + JavaScript + Batch | **License**: MIT \n**Latest Release**: v1.0.0 — June 8, 2026\n\nMakes Reasonix **truly portable** — USB drive or cloud-sync folder, plug into any Windows machine, and all memories, conversations, skills, and MCP configurations come with you.\n\n### The Problem\n\n| Symptom | Root Cause |\n|---------|------------|\n| Memories vanish | Project memory directory hashed by workspace path; path changes → hash mismatch |\n| Conversation history blank | Session metadata stores old workspace path; mismatch → all filtered out |\n| Skills / MCP broken | `config.json` hardcodes MCP paths with old drive letter (E:\\Reasonix) |\n| Nothing reads from here | Reasonix defaults to `C:\\Users\\...`, never looks at current directory |\n\n### How It Works\n\n```\n双击 启动Reasonix.bat\n │\n ├─ 1. Auto-detect current path (any drive, any folder)\n ├─ 2. Tell Reasonix: \"Your data is here, not in C:\\Users\\...\"\n ├─ 3. SHA1(path) → project hash → create dirs; auto-rename/merge old hash dirs\n ├─ 4. Merge local machine's Reasonix data (newer-only, no overwrites)\n ├─ 5. Patch config.json — old paths → current path\n ├─ 6. Repair all session metadata (strip BOM + fix JSON escaping + align workspace)\n └─ 7. Launch Reasonix\n```\n\n### Files\n\n| File | Size | Purpose |\n|------|------|---------|\n| `启动Reasonix.bat` | 0.6 KB | Entry point |\n| `_patch_config.ps1` | 6 KB | Core engine |\n| `_fix_sessions.js` | 2.7 KB | Session metadata repair |\n| `PortaKit-核心规则.md` | 2.4 KB | Core rules for Reasonix to remember portable setup |\n\n### Usage\n\n1. Ensure Reasonix has been launched at least once (API key configured)\n2. Copy PortaKit files into the Reasonix directory (next to `reasonix-desktop.exe`)\n3. Always launch via `启动Reasonix.bat` — never double-click `reasonix-desktop.exe` directly\n4. (Recommended) Import `PortaKit-核心规则.md` into Reasonix so it learns the portable mechanism\n5. After one bat launch, the entire folder becomes fully portable:\n - USB drive → any Windows PC → double-click bat → everything intact\n - Sync folder (OneDrive, Dropbox, etc.) → new PC sync → double-click bat → local data merged in\n\n---\n\n## 9. Protocol Landscape: MCP / ACP / CDP\n\n### MCP (Model Context Protocol) — ✅ Well Established\n\nMCP is the **dominant extension protocol** in the Reasonix ecosystem. Reasonix is a first-class MCP client with three transport types:\n\n- **`stdio`** (default) — launches a local subprocess; one JSON message per line over stdin/stdout\n- **`http`** (Streamable HTTP) — remote server at a URL; POST with optional SSE stream\n- **`sse`** — legacy 2024-11-05 transport; recognized but deferred (deprecated upstream)\n\nTools surface to the model as `mcp____`; a tool declaring MCP's `readOnlyHint: true` joins parallel dispatch. MCP prompts appear as `/mcp____` slash commands; MCP resources are pulled in via `@:`.\n\n**`.mcp.json` drop-in**: Reasonix reads a project-root `.mcp.json` using Claude Code's exact `mcpServers` schema — an existing Claude Code MCP config works in Reasonix unchanged.\n\n**`/mcp`** — Lists connected servers and what each exposes.\n\n| Integration | Type | MCP Tools |\n|-------------|------|-----------|\n| reasonix-mcp-server | Claude Code ↔ Reasonix | `reasonix_run`, `reasonix_doctor` |\n| reasonix-bridge | Codex ↔ DeepSeek | `execute_task`, `plan_task`, `orchestrate_task`, etc. |\n| opencode-reasonix-mcp | OpenCode ↔ Reasonix | Task dispatch |\n| Hindsight-Reasonix (MCP mode) | Memory retrieval | `hindsight_recall`, `hindsight_reflect`, `hindsight_retain` |\n| codegraph (built-in) | Semantic code index | Daemon process with MCP interface |\n\nReasonix has **native MCP support** built into its TOML config:\n\n```toml\n[[plugins]]\nname = \"hindsight\"\ncommand = \"python\"\nargs = [\"/path/to/hindsight_mcp.py\"]\n\n# Remote MCP over Streamable HTTP\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n```\n\nThe reference MCP stdio plugin (`cmd/reasonix-plugin-example`) ships as a runnable example with `echo`, `wordcount`, a `review` prompt, and a `style-guide` resource.\n\n### ACP (Agent Communication Protocol) — ✅ Implemented (v1.6.0+)\n\n**Confirmed in the v1.6.0 changelog** (Jun 10, 2026):\n\n```\nfix(cli): disable codegraph in ACP session config test (#3663)\n```\n\nThis confirms Reasonix has a native **ACP (Agent Communication Protocol)** implementation. ACP sessions run with codegraph indexing disabled to avoid daemon interference, which means ACP is a distinct session mode with its own constraints.\n\nOther ACP-adjacent projects and patterns in the ecosystem:\n\n- **[collab-cli](https://github.com/Staryinsang0910-star/collab-cli)** (⭐7) — Universal collaboration protocol + CLI for multi-agent LLM teams. Supports Claude Code, Reasonix, Codex, WorkBuddy, and Cursor as peers in a shared agent mesh. Defines how heterogeneous agents discover each other, delegate work, share context, and synchronize results.\n- **reasonix-bridge** — The \"Scientist Team\" pattern defines role-based inter-agent communication (Dijkstra ↔ Hopper ↔ Turing, etc.) with interface contract extraction between parallel agents.\n- **council skill** (awesome-reasonix) — Multi-agent discussion and decision-making pattern.\n- **Two-model collaboration** (Reasonix native) — The Coordinator runs an executor and planner in separate cache-stable sessions, handing off structured plans between them — a form of intra-tool agent communication.\n\n### CDP (Context Data Protocol) — 🔶 Emerging\n\nNo project explicitly branded \"CDP\" was found. The closest implementations:\n\n- **[Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix)** — Provides cross-session context persistence via MCP tools (`hindsight_recall`, `hindsight_retain`, `hindsight_reflect`). Functions as a context data layer that survives across sessions.\n- **Reasonix's built-in memory** (`.reasonix/memory.md`) — Persistent across sessions, loaded at startup.\n- **Reasonix's semantic index** — Codebase-wide context for faster file discovery.\n\n---\n\n## 10. Use Cases & Workflows\n\n### 10.1 Low-Cost AI Coding (Daily Development)\n\n**Stack**: Reasonix core (DeepSeek V4 Flash default)\n\nThe primary use case. Point Reasonix at a codebase, describe what you want, and it reads files, writes code, runs commands, and iterates. Costs $0.03–$0.15 per typical session thanks to 99.8% prefix cache hits.\n\n**Best for**: Feature development, bug fixing, refactoring, code review, test generation.\n\n```bash\ncd your-project\nreasonix chat\n> Add input validation to the signup form. Email must be valid, password minimum 8 chars.\n> Also add a confirm password field that must match.\n```\n\n### 10.2 Hybrid Claude Code + DeepSeek Workflow\n\n**Stack**: Claude Code + reasonix-mcp-server + Reasonix (DeepSeek)\n\nUse Claude Code for high-level reasoning, architecture design, and complex orchestration. Route the heavy file-editing work to Reasonix/DeepSeek via MCP to save ~80% on token costs.\n\n**Best for**: Teams already using Claude Code who want to reduce API spend without sacrificing quality.\n\n```\nClaude Code (orchestrator)\n → reasonix_run(\"refactor auth module to use JWT\")\n → Reasonix/DeepSeek (executor, $0.05)\n → Claude reviews the diff\n → reasonix_run(\"add tests for the new JWT module\")\n```\n\n### 10.3 Multi-Agent Parallel Development\n\n**Stack**: Codex + reasonix-bridge + Scientist Team\n\nCodex delegates complex multi-module tasks to reasonix-bridge, which automatically decomposes the work and assigns parallel sub-agents (Dijkstra for backend, Hopper for frontend, Turing for database, etc.).\n\n**Best for**: Full-stack feature development, multi-module refactors, greenfield project scaffolding.\n\n```\nCodex → execute_task(\"Build a blog system with backend API, frontend UI, and database\")\n → Bridge auto-detects complexity → multi-agent mode\n ├─ Dijkstra (backend): API routes, controllers, services\n ├─ Hopper (frontend): React components, pages, routing\n └─ Turing (database): schema, migrations, queries\n → Parallel execution, interface contracts extracted\n → All results merged\n```\n\n### 10.4 CI/CD Automation\n\n**Stack**: `reasonix run \"task\"` (non-interactive, piped input)\n\nThe `run` mode is designed for automation. It accepts piped input, runs non-interactively, and exits with appropriate status codes.\n\n**Best for**: Automated code review, test generation, documentation updates, security scanning.\n\n```bash\n# In CI pipeline:\ngit diff HEAD~1 | reasonix run \"review these changes for security issues\"\nreasonix run \"generate documentation for the ./api directory\"\nreasonix run \"update CHANGELOG.md based on git log since last tag\"\n```\n\n### 10.5 Cross-Session Persistent Memory\n\n**Stack**: Hindsight-Reasonix (hooks + MCP)\n\nEvery conversation turn is automatically recalled (pre-turn) and retained (stop). Memory persists across sessions without manual management. The optional MCP server enables active search.\n\n**Best for**: Long-running projects where institutional knowledge must accumulate over weeks/months; onboarding new agents to existing codebases.\n\n```\nSession 1: \"Set up the project structure\"\n → Hindsight retains: architecture decisions, tech stack, conventions\n\nSession 2 (next day): \"Add the payment module\"\n → Hindsight recalls previous session's context\n → LLM sees it automatically as \n → No need to re-explain the project structure\n```\n\n### 10.6 Portable Development Environment\n\n**Stack**: Reasonix PortaKit + USB drive / cloud sync\n\nCarry your entire Reasonix environment (memories, skills, MCP config, conversations) on a USB drive. Plug into any Windows machine, double-click the bat file, and everything is exactly where you left it.\n\n**Best for**: Developers who work across multiple machines (work PC, home PC, laptop); freelancers; educators.\n\n### 10.7 Community Skill Extensions\n\n**Stack**: awesome-reasonix skills\n\nExtend Reasonix with 80+ community-contributed skills covering everything from file organization to multi-agent councils. New skills can be installed in seconds.\n\n**Best for**: Customizing Reasonix for specific workflows, languages, or domains.\n\n```bash\n# Install\ncurl -o .reasonix/skills/deep-research.md https://raw.githubusercontent.com/.../deep-research.md\n# Use\n> /skill deep-research \"Compare the top 5 Rust web frameworks\"\n```\n\n### 10.8 Multi-Agent Collaboration Protocol\n\n**Stack**: collab-cli\n\nUse multiple different agent systems (Claude Code + Reasonix + Codex + Cursor) in a coordinated mesh, each playing to its strengths.\n\n**Best for**: Teams using heterogeneous agent tooling; complex pipelines requiring specialized agents.\n\n---\n\n## 11. Appendix: Quick-Reference Links\n\n### Core\n\n| Resource | URL |\n|----------|-----|\n| Reasonix Main Repo (v1.0 Go) | [github.com/esengine/DeepSeek-Reasonix](https://github.com/esengine/deepseek-reasonix) |\n| Official Website | [reasonix.homes](https://reasonix.homes) |\n| DeepSeek Official Docs | [api-docs.deepseek.com/.../reasonix](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix) |\n| npm Package | [npmjs.com/package/reasonix](https://www.npmjs.com/package/reasonix) |\n| brew Formula | `brew install esengine/reasonix/reasonix` |\n| Architecture Docs | [esengine.github.io/DeepSeek-Reasonix](https://esengine.github.io/DeepSeek-Reasonix/) |\n\n### Official Docs (in repo)\n\n| Document | URL |\n|----------|-----|\n| GUIDE.md (Configuration & Usage) | [github.com/.../docs/GUIDE.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md) |\n| GUIDE.zh-CN.md (简体中文指南) | [github.com/.../docs/GUIDE.zh-CN.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.zh-CN.md) |\n| SPEC.md (Engineering Contract) | [github.com/.../docs/SPEC.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SPEC.md) |\n| CHECKPOINTS.md (Rewind Design) | [github.com/.../docs/CHECKPOINTS.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/CHECKPOINTS.md) |\n| MIGRATING.md (0.x → 1.0) | [github.com/.../docs/MIGRATING.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/MIGRATING.md) |\n| REASONIX.md (Project Memory) | [github.com/.../REASONIX.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/REASONIX.md) |\n| SESSION_REFERENCE_ARCHITECTURE.md | [github.com/.../docs/SESSION_REFERENCE_ARCHITECTURE.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SESSION_REFERENCE_ARCHITECTURE.md) |\n\n### Community\n\n| Resource | URL |\n|----------|-----|\n| Discord (bilingual) | [discord.gg/XF78rEME2D](https://discord.gg/XF78rEME2D) |\n| GitHub Issues | [github.com/.../issues](https://github.com/esengine/deepseek-reasonix/issues) (266 open) |\n| GitHub Discussions | [github.com/.../discussions](https://github.com/esengine/deepseek-reasonix/discussions) |\n| Pull Requests | [github.com/.../pulls](https://github.com/esengine/deepseek-reasonix/pulls) (48 open) |\n\n### MCP Bridges\n\n| Resource | URL |\n|----------|-----|\n| reasonix-mcp-server (Claude Code ←→ Reasonix) | [github.com/Belveth02/reasonix-mcp-server](https://github.com/Belveth02/reasonix-mcp-server) |\n| reasonix-bridge (Codex ←→ DeepSeek) | [github.com/OwlCodeTech/reasonix-bridge](https://github.com/OwlCodeTech/reasonix-bridge) |\n| opencode-reasonix-mcp | [github.com/StarWHAT-BUG/opencode-reasonix-mcp](https://github.com/StarWHAT-BUG/opencode-reasonix-mcp) |\n| collab-cli (Universal Protocol) | [github.com/Staryinsang0910-star/collab-cli](https://github.com/Staryinsang0910-star/collab-cli) |\n\n### Skills & Community\n\n| Resource | URL |\n|----------|-----|\n| awesome-reasonix (Hub) | [github.com/hikari-2424/awesome-reasonix](https://github.com/hikari-2424/awesome-reasonix) |\n| Skills Site (Browse) | [hikari-2424.github.io/awesome-reasonix-site](https://hikari-2424.github.io/awesome-reasonix-site/) |\n| Full Registry JSON | [github.com/.../registry.json](https://github.com/hikari-2424/awesome-reasonix/blob/main/registry.json) |\n\n### Memory & Portability\n\n| Resource | URL |\n|----------|-----|\n| Hindsight-Reasonix (Memory) | [github.com/houycth/Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix) |\n| Reasonix PortaKit | [github.com/CS-Faith/reasonix-portakit](https://github.com/CS-Faith/reasonix-portakit) |\n\n### Guides & Comparisons\n\n| Resource | URL |\n|----------|-----|\n| Complete Guide (aimadetools) | [aimadetools.com/blog/reasonix-complete-guide](https://www.aimadetools.com/blog/reasonix-complete-guide) |\n| Setup Guide | [aimadetools.com/blog/how-to-use-reasonix](https://www.aimadetools.com/blog/how-to-use-reasonix) |\n| Reasonix vs Claude Code | [aimadetools.com/...](https://www.aimadetools.com/blog/reasonix-vs-claude-code) |\n| Reasonix vs Aider | [aimadetools.com/...](https://www.aimadetools.com/blog/reasonix-vs-aider-for-deepseek) |\n\n### Distribution\n\n| Channel | Details |\n|---------|---------|\n| npm | `npm i -g reasonix` (pulls prebuilt Go binary) |\n| Homebrew | `brew install esengine/reasonix/reasonix` |\n| Prebuilt Archives | 6 targets: darwin/linux/windows × amd64/arm64 + SHA256SUMS |\n| Debian | `.deb` package built on release |\n| Source | `make build` (→ `bin/reasonix`) / `make cross` (→ `dist/`) |\n| Windows Signing | [SignPath Foundation](https://signpath.io/) (free certificate) |\n\n### Contributors & Acknowledgments\n\nTop contributors (alphabetical, by commit count + code volume): **ctharvey**, **dimasd-angga**, **Evan-Pycraft**, **ForeverYoungPp**, **GTC2080**, **kabaka9527**, **lisniuse**, **wade19990814-hue**, **wviana**.\n\nLogo by **Bernardxu123**. Community promotion on XiaoHongShu (小红书) by **AIGC Link**.\n\n### Donations\n\nReasonix is MIT-licensed and free. Donations stay \"a coffee, not a contract\" — they don't buy feature priority or change issue triage. [PayPal: paypal.me/yuhuahui](https://paypal.me/yuhuahui) · WeChat Pay (QR in README).\n\n---\n\n> **Document**: Updated Jun 2026 from primary source `esengine/DeepSeek-Reasonix` (main-v2, v1.6.0, Jun 10 2026), community GitHub repos, DeepSeek API docs, npm, aimadetools, reasonix.homes. \n> **Note**: The \"Reasonix\" name also refers to [reasonixos.com](https://reasonixos.com/about) — an \"Integrated Intelligence Company\" (AI-powered enterprise decision systems / AI Growth OS for marketing). This document covers only the DeepSeek-native coding agent ecosystem, not the enterprise platform.\n"}, "272870a8c343f578ce5e7ab32521ee19c1c61c58": {"t": 1785750399.4571927, "n": 53174, "h": "6f13b70b2f6c4a45", "c": "# Reasonix + DeepSeek Ecosystem: Complete Reference (2026)\n\n> A comprehensive survey of integrations, plugins, tools, skills, MCP bridges, protocols, and use cases in the Reasonix ecosystem as of mid-2026.\n\n---\n\n## Table of Contents\n\n1. [Overview: What is Reasonix?](#1-overview-what-is-reasonix)\n2. [Core Architecture & Cost Model](#2-core-architecture--cost-model)\n3. [Official Integration (DeepSeek Docs)](#3-official-integration-deepseek-docs)\n4. [Desktop Application v1.5.0](#4-desktop-application-v150)\n - 4.1 [Key Desktop Features](#key-desktop-features)\n - 4.2 [Bot Gateway (Feishu / Weixin / QQ)](#bot-gateway-v150)\n - 4.3 [Desktop vs CLI](#desktop-vs-cli)\n5. [MCP Bridges & Cross-Platform Integrations](#5-mcp-bridges--cross-platform-integrations)\n - 5.1 [reasonix-mcp-server (Claude Code ↔ Reasonix)](#51-reasonix-mcp-server-claude-code--reasonix)\n - 5.2 [reasonix-bridge (Codex ↔ DeepSeek, Multi-Agent)](#52-reasonix-bridge-codex--deepseek-multi-agent)\n - 5.3 [opencode-reasonix-mcp (OpenCode Bridge)](#53-opencode-reasonix-mcp-opencode-bridge)\n - 5.4 [collab-cli (Universal Multi-Agent Protocol)](#54-collab-cli-universal-multi-agent-protocol)\n6. [Community Skills Hub (awesome-reasonix)](#6-community-skills-hub-awesome-reasonix)\n - 6.1 [Full Skill Registry](#61-full-skill-registry)\n - 6.2 [Skill Categories](#62-skill-categories)\n7. [Long-Term Memory: Hindsight-Reasonix](#7-long-term-memory-hindsight-reasonix)\n8. [Portable Toolkit: Reasonix PortaKit](#8-portable-toolkit-reasonix-portakit)\n9. [Protocol Landscape: MCP / ACP / CDP](#9-protocol-landscape-mcp--acp--cdp)\n10. [Use Cases & Workflows](#10-use-cases--workflows)\n11. [Appendix: Quick-Reference Links](#11-appendix-quick-reference-links)\n\n---\n\n## 1. Overview: What is Reasonix?\n\n**Reasonix** is an open-source (MIT), terminal-based AI coding agent built specifically for **DeepSeek models**. It is listed on DeepSeek's official API documentation as a recommended integration, giving it a degree of first-party endorsement that few third-party tools have.\n\n| Attribute | Value |\n|-----------|-------|\n| **License** | MIT (fully open source) |\n| **Language** | Go (1.0, `main-v2` branch — current); TypeScript (0.x, `v1` branch — legacy/maintenance) |\n| **GitHub Stars** | 20,700+ |\n| **Forks** | 1,200+ |\n| **Releases** | 46 (v1.5.0 — Jun 10, 2026) |\n| **Commits** | 777+ |\n| **Default Model** | DeepSeek V4 Flash (via config TOML); also presets for DeepSeek V4 Pro, MiMo-v2.5-pro, MiMo-v2.5 |\n| **Upgrade Model** | DeepSeek V4 Pro or MiMo-v2.5-pro (per-model via `/model` or `/preset max`) |\n| **Platforms** | macOS (brew + prebuilt), Linux (.deb + prebuilt), Windows (prebuilt, code-signed via SignPath) |\n| **Config Format** | TOML (`reasonix.toml` — flag > project > user > defaults resolution) |\n| **Node Requirement** | None for v1.0 Go binary; Node ≥ 22 only for npm install shim or legacy 0.x |\n| **Alias** | `dsnix` |\n| **Install** | `npm i -g reasonix`, `brew install esengine/reasonix/reasonix`, prebuilt archives (6 targets), `make build`/`make cross` from source |\n| **Official Docs** | [DeepSeek Agent Integrations](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix); [Guide (GUIDE.md)](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md); [Spec (SPEC.md)](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SPEC.md) |\n\n### Three Operating Modes\n\n| Mode | Command | Description |\n|------|---------|-------------|\n| **chat** (default) | `reasonix chat` | Interactive conversation with full tool access. Run `/init` to generate AGENTS.md project memory. Use for actual development. |\n| **run** (one-shot) | `reasonix run \"task\"` | Executes a single task and exits. Accepts piped input and `--model` flag. Ideal for CI/CD, scripting, automation pipelines. |\n| **code** | `reasonix chat` | Legacy alias preserved from 0.x; routes to `chat` in v1.0+. |\n\n### Model Switching\n\n- **`/model`** — Interactively switch the active model mid-session; selection persists to user config.\n- **`/preset max`** — Switches the entire session to a higher-tier model (e.g. DeepSeek V4 Pro).\n- **Two-model collaboration** — Set `[agent].planner_model` in `reasonix.toml` to run a separate low-frequency planner (e.g. MiMo-v2.5-pro) alongside the executor, each in its own cache-stable session.\n- **Subagent models** — `subagent_model` and `subagent_models` config keys let specific skills (like `review`, `security_review`) run on different models.\n- **Multi-provider presets** — DeepSeek (flash/pro) and MiMo (mimo-v2.5-pro / mimo-v2.5) ship as config presets; any OpenAI-compatible endpoint is a config entry.\n\n### Built-in Features\n\n- **Plan mode** — Auto-plan (`/auto-plan on`) or manual (`/plan`); two-model planner with read-only research tools before executor acts.\n- **Checkpoints & Rewind** — File-snapshot-based undo (`Esc-Esc` or `/rewind`); restore code, conversation, or both; fork-from-here; works without touching `.git`.\n- **Goal mode** — `/goal ` starts an autonomous, session-scoped active goal loop with blocked-state audit; desktop-only.\n- **Memory** — Hierarchical `REASONIX.md` (committed) + `REASONIX.local.md` (personal, gitignored) + user-global `~/.config/reasonix/REASONIX.md` + ancestor dirs; `AGENTS.md` accepted as fallback.\n- **`remember` tool** — Saves durable facts to per-project auto-memory (frontmatter files + `MEMORY.md` index), loaded into prefix on next session.\n- **`#` quick-add** — Type `#` in chat to quick-add a line to the project's `REASONIX.md`.\n- **`@path` imports** — A line containing `@path/to/another/file.md` injects that file's content into the memory prefix.\n- **Session branching** — `/tree` shows saved conversation branches; `/branch [name]` forks current tip; `/branch [name]` forks from a checkpointed turn; `/switch ` loads another branch.\n- **Subagent transcripts** — `/task` spawns sub-agents; continue/resume past transcripts in v1.5.0+.\n- **MCP/Plugins** — First-class MCP client: stdio, HTTP (Streamable), and SSE transports; `.mcp.json` drop-in from Claude Code works unchanged.\n- **Skills** — Reusable prompt templates in `.reasonix/skills/*.md`; frontmatter supports `runAs: subagent` and `allowed-tools` for isolated execution; `/skill new my-skill` scaffolds one.\n- **`read_skill` tool** — Load inline skills in plan mode without execution (v1.5.0+).\n- **Slash commands** — 20+ built-ins (`/compact`, `/new`, `/clear`, `/rewind`, `/tree`, `/branch`, `/switch`, `/todo`, `/model`, `/mcp`, `/skills`, `/hooks`, `/memory`, `/output-style`, `/sandbox`, `/language`, `/auto-plan`, `/help`); custom commands in `.reasonix/commands/*.md`.\n- **Chat references (`@`)** — `@path/to/file` injects file contents (or directory listing) as tagged context; `@:` injects MCP resources. Autocomplete on `/` or `@`.\n- **Web search** — Mojeek, SearXNG, or Metaso backends.\n- **Transcript replay & events** — Every event hits disk; `reasonix replay`, `reasonix events`, `reasonix stats`.\n- **Bot gateway** — Feishu/Weixin/QQ adapters (v1.5.0+).\n- **PDF extraction** — Extract text from PDF attachments (v1.5.0+).\n- **Sandbox** — macOS Seatbelt enforcement for Bash (jailed to workspace + temp + toolchain caches, network opt-in); file-writers confined to `workspace_root` with symlink/`..` traversal prevention; Linux bubblewrap/landlock on roadmap.\n- **Permisssions** — `deny > ask > allow > fallback` per-tool-call gating; tool-approval postures: ask, auto, yolo.\n- **Context compaction** — Low-frequency cache-reset point when approaching `context_window` limit; older middle of session summarized; dropped originals archived as JSONL.\n- **Codegraph** — Semantic code index for faster file discovery (daemon process with MCP interface).\n\n---\n\n## 2. Core Architecture & Cost Model\n\n### Cache-First Philosophy\n\nReasonix is engineered around **DeepSeek's byte-stable prefix cache**. Every LLM API provider offers prefix caching, but DeepSeek's is particularly aggressive — if the start of a prompt matches a cached prefix exactly, the input token cost drops to ~1/5th the normal rate (per official reasonix.homes: ~94% cache hit, ~2.5× cost reduction). Any change invalidates the cache.\n\nGeneric tools restructure prompts for correctness. Reasonix structures them for **cache stability**:\n\n1. **Fixed prefix ordering** — System prompt, project context, and memory are always in the same position. New information is appended, never inserted.\n2. **Incremental context growth** — Files added to context go at the end. Nothing before them shifts.\n3. **Stable tool call formatting** — Tool results are formatted identically every time.\n\n### Real-World Cost Numbers\n\n| Session Type | Input Tokens | Cache Hit Rate | Effective Cost | Without Cache |\n|:-------------|:-------------|:---------------|:---------------|:--------------|\n| Quick fix (5 min) | 50K | 99.5% | ~$0.01 | ~$0.04 |\n| Feature (30 min) | 500K | 99.8% | ~$0.05 | ~$0.24 |\n| Refactor (2 hrs) | 3M | 99.9% | ~$0.15 | ~$1.31 |\n| Full day coding | 20M | 99.8% | ~$0.85 | ~$4.12 |\n\n**Headline stat**: 435M input tokens processed with 99.82% cache hits, costing approximately **$12** instead of the **$61** it would have cost without cache optimization. (Real production data, not a benchmark.)\n\n### Configuration\n\n**Resolution order**: `flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults`.\n\nSecrets come from the environment via `api_key_env` and are **never stored in config files**. A `.env` in the working directory is loaded if present. Step-limit preferences belong in user config; project `reasonix.toml` should override them only when the repository needs shared runtime bounds.\n\n**Minimal `reasonix.toml`** — one provider and a default model is enough to start:\n\n```toml\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n```\n\n**Full example**:\n\n```toml\ndefault_model = \"deepseek-flash\" # executor; set [agent].planner_model to add a planner\n\n[agent]\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\n# planner_model = \"mimo-pro\" # optional low-frequency planner\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" }\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n\n[tools]\nenabled = [] # omit/empty = all built-ins\nbash_timeout_seconds = 120\n\n[skills]\npaths = [] # extra custom skill roots\nexcluded_paths = [] # hide convention roots without deleting\ndisabled_skills = [] # hidden from prompt, slash invocation, and skill tools\n\n[permissions]\nmode = \"ask\" # writer fallback: ask|allow|deny\ndeny = [\"Bash(rm -rf*)\", \"Bash(git push*)\"] # hard-blocked in every mode\nallow = [\"Bash(go test:*)\"] # never prompted\n\n[sandbox]\n# workspace_root = \"\" # file-writers confined here; empty = current dir\n# allow_write = [\"/tmp\"]\n\n[[plugins]]\nname = \"github\"\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-github\"]\n\n# Remote MCP server over Streamable HTTP\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n```\n\n**`.mcp.json` drop-in support**: Reasonix also reads a project-root `.mcp.json` using Claude Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`, `${VAR}` expansion). Both sources are merged; on a name collision `reasonix.toml` wins.\n\n```json\n{\n \"mcpServers\": {\n \"filesystem\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/path\"] },\n \"stripe\": { \"type\": \"http\", \"url\": \"https://mcp.stripe.com\", \"headers\": { \"Authorization\": \"Bearer ${STRIPE_KEY}\" } }\n }\n}\n```\n\n---\n\n## 3. Official Integration (DeepSeek Docs)\n\nDeepSeek's official API documentation includes Reasonix as a **recommended agent integration** alongside Claude Code, GitHub Copilot, WorkBuddy, OpenCode, Kilo Code, and others.\n\n**Installation**:\n\n```bash\n# Any OS — pulls the prebuilt Go native binary\nnpm i -g reasonix\n\n# macOS\nbrew install esengine/reasonix/reasonix\n\n# Prebuilt archives for all platforms (darwin|linux|windows × amd64|arm64)\n# Available on every GitHub release, with SHA256SUMS\n\n# Build from source\ngit clone https://github.com/esengine/deepseek-reasonix.git\ncd deepseek-reasonix\nmake build # -> bin/reasonix(.exe)\nmake cross # -> dist/ (6 targets)\n```\n\nWindows builds are **code-signed** with a free certificate provided by the [SignPath Foundation](https://signpath.io/). Linux `.deb` packages are built on release.\n\n**Quick-start** (from [deepseek.com](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix)):\n\n```bash\n# 1. Get a DeepSeek API Key from platform.deepseek.com\n# 2. Run the config wizard:\nreasonix setup # writes ./reasonix.toml\n\n# 3. Start chatting:\nexport DEEPSEEK_API_KEY=sk-... # or put it in .env\nreasonix chat # then run /init to generate AGENTS.md\n\n# Or one-shot:\nreasonix run \"implement the TODOs in main.go\"\nreasonix run --model mimo-pro \"add unit tests for this function\"\necho \"explain this code\" | reasonix run\n```\n\nNo Node.js required for v1.0+ (single Go binary). First launch with `reasonix setup` writes a minimal `reasonix.toml`. Secrets stay in environment variables (`api_key_env`), never in config files.\n\n---\n\n## 4. Desktop Application v1.5.0\n\n**Built into the main repo**: [desktop/](https://github.com/esengine/deepseek-reasonix/tree/main-v2/desktop) directory in `esengine/DeepSeek-Reasonix`. \n**Stack**: Wails + React 19 + TypeScript 6 | **Latest**: v1.5.0 (Jun 10, 2026)\n\nThe desktop app is a **visual companion to the CLI**, not a Cursor replacement. It provides a GUI wrapper around the same Go controller — the terminal TUI, desktop webview, and HTTP/SSE server all drive the same `control.Controller`, so every feature surfaces identically across frontends.\n\n### Key Desktop Features\n\n- **Themed workspace UI** — Dark and light themes with a full sidebar: session list, file tree, MCP settings panel, skills browser, and memory view. Screenshots at `docs/desktop-theme-dark.jpg` and `docs/desktop-theme-light.jpg`.\n- **Collaboration-mode picker** — Three choices: **normal** (standard chat), **plan** (`/plan`), and **goal** (`/goal`).\n- **Goal mode** (`/goal `) — Starts an autonomous, session-scoped active goal loop. The controller prepends goal context to user turns (outside the cache-stable system prompt) and keeps issuing continuation turns until the model reports completion, repeats the same blocked state three times, or the safety limit is reached. Blocked-state matching is normalized for casing, whitespace, and punctuation. `/goal clear` removes the active goal.\n- **Tool-approval postures** — **ask** (each writer/bash call prompts), **auto** (writer fallback auto-allowed, explicit ask/deny rules still apply), **yolo** (approval prompts auto-allowed unless denied). These are separate from the collaboration-mode picker.\n- **`read_skill` tool** — Load inline skills in plan mode without execution (v1.5.0+).\n- **Session workspace isolation** — Desktop sessions are isolated per workspace directory; switching workspaces loads the correct session history.\n- **MCP settings panel** — View connected servers, connection status, retry failures, inspect each server's tools/prompts/resources. Collapses noisy startup errors into a summary.\n- **Reveal-in-file-manager** — Right-click any file in the workspace tree to reveal it in Finder/Explorer.\n- **Image paste** — Paste clipboard images directly into the chat composer.\n- **Model selector dropdown** — Switch models interactively; scroll support and tooltips for truncated names; preserves curated provider models on refresh.\n- **Rewind (hover)** — Each user message in the transcript has a hover rewind control → menu: rewind code / rewind conversation / both / fork-from-here.\n- **Subagent transcript continue/resume** — Continue past subagent transcripts from where they left off (v1.5.0+).\n- **PDF attachment text extraction** — Extract text from PDF attachments dropped into the chat (v1.5.0+).\n\n### Bot Gateway (v1.5.0+)\n\nReasonix ships a **bot gateway** with adapters for:\n- **Feishu (飞书)** — Chinese enterprise messaging\n- **Weixin (微信)** — China's dominant messaging app\n- **QQ** — Tencent's instant messaging platform\n\nThe gateway receives messages from these platforms, routes them through the Reasonix agent, and sends responses back. Each adapter handles authentication (Feishu webhook tokens, Weixin account/login). This turns Reasonix into a deployable AI coding assistant accessible from chat platforms — useful for teams that want a shared coding agent reachable from their existing messaging tools.\n\n### Desktop vs CLI\n\n| Feature | CLI (TUI) | Desktop |\n|---------|-----------|---------|\n| Chat | Full transcript, scrolling (Ctrl+Home/End in v1.5.0) | Visual transcript with hover controls |\n| Plan mode | `/plan` | Collaboration-mode picker |\n| Goal mode | Not available | `/goal` with blocked-state audit |\n| Checkpoints/Rewind | `Esc-Esc` / `/rewind` picker | Hover rewind on each message |\n| MCP management | `/mcp` slash command | Full settings panel with retry |\n| Skills | `/skills` | Visual browser |\n| File tree | `/tree` | Sidebar with reveal-in-file-manager |\n| Image paste | Via file path | Clipboard paste |\n| Bot gateway | Available via serve mode | Included |\n\n---\n\n## 5. MCP Bridges & Cross-Platform Integrations\n\n### 5.1 reasonix-mcp-server (Claude Code ↔ Reasonix)\n\n**Repo**: [github.com/Belveth02/reasonix-mcp-server](https://github.com/Belveth02/reasonix-mcp-server) \n**Stars**: ⭐2 | **Language**: JavaScript | **License**: MIT\n\nThis MCP server lets **Claude Code call Reasonix as a DeepSeek sub-agent** through the Model Context Protocol. The core idea: use Claude for high-level reasoning and orchestration, but delegate the actual file-editing work to Reasonix (which costs ~1/5 as much thanks to prefix caching).\n\n#### Tools Exposed\n\n| Tool | Parameters | Description |\n|------|------------|-------------|\n| `reasonix_run` | `task` (string, required), `model` (string, optional), `effort` (enum: low/medium/high/max), `budget` (number, optional), `timeout` (number, default 300000ms) | Execute a coding task via Reasonix |\n| `reasonix_doctor` | `json` (boolean, optional) | Run health check: API key, network, config |\n\n#### Installation\n\n```bash\nnpm install -g reasonix\ngit clone https://github.com/Belveth/reasonix-mcp-server.git\ncd reasonix-mcp-server\nnpm install\n```\n\nRegister with Claude Code:\n\n```bash\nclaude mcp add reasonix --scope user -- \\\n --command node \\\n --args \"/absolute/path/to/server.js\"\n```\n\nOr manually edit `~/.claude.json`:\n\n```json\n\"mcpServers\": {\n \"reasonix\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\"C:\\\\Users\\\\xxx\\\\.claude\\\\tools\\\\reasonix-mcp-server\\\\server.js\"],\n \"env\": {}\n }\n}\n```\n\n#### Cost Comparison\n\nIn real-world testing: 435M tokens processed at ~$12 via Reasonix/DeepSeek. Equivalent work on Claude: ~$61. That's an **~80% cost reduction** by routing the heavy file-editing work through Reasonix's cache-optimized DeepSeek pipeline.\n\n---\n\n### 5.2 reasonix-bridge (Codex ↔ DeepSeek, Multi-Agent)\n\n**Repo**: [github.com/OwlCodeTech/reasonix-bridge](https://github.com/OwlCodeTech/reasonix-bridge) \n**Stars**: ⭐6 | **Language**: JavaScript (~1500 lines, single file) | **License**: MIT\n\nA **multi-agent parallel execution engine** that lets **Codex delegate tasks to DeepSeek via MCP**. Supports two-phase execution, multi-agent parallelism, incremental mode, and dynamic budget allocation.\n\n#### Architecture\n\n```\nCodex (MCP Client)\n │ stdio pipe\n ▼\nindex.js (MCP Server)\n │\n ├─ 6 MCP tools\n ├─ 8 local sandbox tools (read_file / write_file / run_command / ...)\n └─ DeepSeek API (streaming)\n ├─ Planning mode (no tools, produces plan)\n └─ Execution mode (8 tools, agent loop)\n```\n\n#### Tools Exposed\n\n| Tool | Purpose | Recommended Use |\n|------|---------|-----------------|\n| `execute_task` | Auto-detect: single or multi-agent | Daily use |\n| `plan_task` | Generate plan only, no execution | \"Show me the plan first\" |\n| `execute_step` | Execute a specific step | Pair with `plan_task` |\n| `orchestrate_task` | Force multi-agent parallelism | When explicit division of labor is needed |\n| `improve_file` | DeepSeek-targeted fix; Codex retains override | When Codex finds something to fix |\n| `delegate_to_reasonix` | Legacy one-shot execution | Backward compatibility |\n\n#### The \"Scientist Team\" (Role-Based Parallel Agents)\n\nWhen a task requires multi-agent execution, Bridge assigns roles automatically:\n\n| Emoji | Scientist | Role | Domain |\n|:-----|:----------|:-----|:--------|\n| 🖥️ | Dijkstra | Backend Engineer | Servers, APIs, algorithms |\n| 🎨 | Hopper | Frontend Developer | UI, interactions, components |\n| 🗄️ | Turing | Data Engineer | Database, storage |\n| 🔗 | Berners-Lee | Interface Engineer | API communication |\n| 🧪 | Lovelace | Test Engineer | Validation, deployment |\n\n**Sub-agent decomposition**: A single-domain complex task auto-splits:\n```\n🖥️ Dijkstra\n ├─ Dijkstra-A (user auth module)\n ├─ Dijkstra-B (CRUD operations)\n └─ Dijkstra-C (file upload)\n```\n\n#### Two-Phase Execution\n\n1. **Phase 1 (Planning)**: DeepSeek decomposes task → JSON step list (5-10 seconds)\n2. **Phase 2 (Execution)**: Step-by-step or multi-agent parallel (each step has independent context)\n - Previous outputs already on disk, unaffected by subsequent budget limits\n\n**Parallel scheduling**: Independent sub-tasks → `Promise.all` parallel execution. Dependent sub-tasks → wait for dependencies. Interface contracts extracted after each step and passed to subsequent agents.\n\n#### Security Model: 5-Level Command Control\n\n| Mode | Permissions |\n|------|-------------|\n| `off` | No commands, read-only files |\n| `readonly` | `pwd`, `echo`, `which` |\n| `static` | + `git` (read-only), `node --check`, `tsc` |\n| `verify` | + `npm test`, `eslint`, `jest` |\n| `full` | All open + write files |\n\nAdditional security: file sandbox (validates all paths, rejects traversal/symlink escapes), hash-based overwrite prevention, read-only mode auto-disables `edit_file`/`write_file`, forced-write audit logging, `REASONIX_MAX_TOKENS` hard cap.\n\n#### Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `DEEPSEEK_API_KEY` | — | Required |\n| `REASONIX_COMMAND_MODE` | `static` | Command permission level |\n| `WORKSPACE_ROOT` | `cwd` | Sandbox root directory |\n| `DEEPSEEK_MODEL` | `deepseek-chat` | Model identifier |\n| `DEEPSEEK_TEMPERATURE` | `0.2` | Temperature |\n| `REASONIX_MAX_TOKENS` | `64000` | Cumulative token budget |\n| `HTTP_PROXY` | — | Proxy (for non-China API access) |\n| `GITHUB_TOKEN` | — | GitHub API rate-limit boost |\n\n---\n\n### 5.3 opencode-reasonix-mcp (OpenCode Bridge)\n\n**Repo**: [github.com/StarWHAT-BUG/opencode-reasonix-mcp](https://github.com/StarWHAT-BUG/opencode-reasonix-mcp) \n**Stars**: ⭐0 | **Language**: JavaScript\n\nDispatches **stateless sub-tasks from OpenCode to Reasonix CLI** via MCP, saving ~99% of token costs compared to running the same tasks on Claude or GPT, by routing them through DeepSeek's prefix-cached pipeline.\n\n---\n\n### 5.4 collab-cli (Universal Multi-Agent Protocol)\n\n**Repo**: [github.com/Staryinsang0910-star/collab-cli](https://github.com/Staryinsang0910-star/collab-cli) \n**Stars**: ⭐7 | **Language**: TypeScript | **Tags**: `mcp`, `collaboration`, `protocol`, `multi-agent`\n\nA **universal collaboration protocol + CLI** for multi-agent LLM teams. Supports **Claude Code, Reasonix, Codex, WorkBuddy, and Cursor** as peers in a shared agent mesh. This is the closest project found to an **ACP (Agent Communication Protocol)** implementation — it defines how different agent systems discover each other, delegate work, share context, and synchronize results.\n\n---\n\n## 6. Community Skills Hub (awesome-reasonix)\n\n**Repo**: [github.com/hikari-2424/awesome-reasonix](https://github.com/hikari-2424/awesome-reasonix) \n**Stars**: ⭐2 | **Language**: HTML / Markdown | **License**: MIT \n**Site**: [hikari-2424.github.io/awesome-reasonix-site/](https://hikari-2424.github.io/awesome-reasonix-site/)\n\nAn **independent community project** (not affiliated with Reasonix) that collects, rates, and shares community-contributed Skills. Skills are Markdown files dropped into `.reasonix/skills/` and invoked via `/skill `.\n\n**Registry version**: 3 | **Last updated**: 2026-05-30 | **80+ published skills**\n\n### Installation\n\n```bash\n# Single skill\ncurl -o .reasonix/skills/file-organizer.md https://raw.githubusercontent.com/hikari-2424/awesome-reasonix/main/skills/file-organizer.md\n\n# Then inside Reasonix:\n> /skill file-organizer\n```\n\n### 6.1 Full Skill Registry\n\n#### File & System\n\n| Skill | Description |\n|-------|-------------|\n| `file-organizer` | Classify files by extension, find duplicates, tree view, clean empty dirs |\n| `disk-cleaner` | Disk space analysis and cleanup automation |\n\n#### Text Processing\n\n| Skill | Description |\n|-------|-------------|\n| `text-polish` | Polish, translate, summarize, rewrite — 4 output styles (professional/casual/concise/tech-doc) |\n| `data-convert` | Convert data between formats (JSON, CSV, YAML, etc.) |\n| `crosspost` | Cross-post content across platforms |\n\n#### Learning & Research\n\n| Skill | Description |\n|-------|-------------|\n| `learn-topic` | Search → fetch → organize → summarize (subagent mode) |\n| `deep-research` | Deep research agent with iterative retrieval |\n| `data-scraper-agent` | Web scraping agent |\n\n#### System Control\n\n| Skill | Description |\n|-------|-------------|\n| `input-control` | Windows mouse/keyboard automation via PowerShell |\n| `knowledge-ops` | Knowledge base management operations |\n\n#### Development Tools\n\n| Skill | Description |\n|-------|-------------|\n| `git-helper` | Conventional Commits, PR descriptions, history analysis, conflict resolution |\n| `code-review` | 4-dimension review (logic, security, performance, style) with file:line output |\n| `changelog-generator` | Auto-generate CHANGELOG.md from git history, semver inference |\n| `database-migrations` | Database migration patterns and automation |\n| `deployment-patterns` | Deployment strategies and CI/CD patterns |\n| `docker-patterns` | Docker and containerization patterns |\n| `github-ops` | GitHub operations automation |\n\n#### Project Generation\n\n| Skill | Description |\n|-------|-------------|\n| `project-scaffold` | Interactive scaffolding: Python, TypeScript, Go, Rust |\n| `readme-gen` | Auto-generate README.md from project structure analysis |\n| `api-connector-builder` | Build API connectors matching existing integration patterns |\n\n#### API & Integration\n\n| Skill | Description |\n|-------|-------------|\n| `api-design` | REST API design patterns: naming, status codes, pagination, filtering, error responses, versioning |\n| `jira-integration` | Jira issue tracking integration |\n\n#### Content & Design\n\n| Skill | Description |\n|-------|-------------|\n| `article-writing` | Write articles, guides, blog posts, tutorials, newsletters |\n| `brand-voice` | Extract writing style profiles from real content, build reusable brand voice config |\n| `code-tour` | Create step-by-step CodeTour files with real file:line anchors |\n| `dashboard-builder` | Build dashboards and data visualizations |\n| `frontend-slides` | Create HTML presentations from scratch or convert PowerPoint |\n| `content-engine` | Content generation and management engine |\n| `liquid-glass-design` | Modern UI design patterns |\n\n#### AI & Agent\n\n| Skill | Description |\n|-------|-------------|\n| `autonomous-loops` | Autonomous agent loop patterns: from simple pipelines to RFC-driven multi-agent workflows |\n| `blueprint` | Turn one-line objective into multi-phase, multi-agent engineering plan with dependency graph |\n| `council` | Multi-agent discussion and decision-making council |\n| `continuous-learning` | Auto-extract reusable patterns from Claude Code sessions |\n| `continuous-learning-v2` | Instinct-based learning system with confidence scoring |\n| `agentic-engineering` | Agent-driven engineering patterns |\n| `agent-harness-construction` | Build agent harnesses for testing and evaluation |\n| `agent-introspection-debugging` | Debug and introspect agent behavior |\n| `agent-sort` | Agent categorization and sorting |\n| `ai-first-engineering` | AI-first development methodology |\n| `eval-harness` | Formal evaluation framework for AI sessions |\n| `cost-aware-llm-pipeline` | Cost-optimized LLM pipeline patterns |\n| `iterative-retrieval` | Progressive context retrieval pattern |\n\n#### Architecture & Standards\n\n| Skill | Description |\n|-------|-------------|\n| `coding-standards` | Cross-project coding conventions: naming, readability, immutability, review criteria |\n| `backend-patterns` | Backend architecture: API design, database optimization, Node.js/Express/Next.js best practices |\n| `frontend-patterns` | Frontend patterns: React, Next.js, state management, performance |\n| `frontend-design` | UI/UX design patterns |\n| `android-clean-architecture` | Clean Architecture for Android/Kotlin Multiplatform |\n| `compose-multiplatform-patterns` | Compose Multiplatform / Jetpack Compose: state, navigation, theming, testing |\n| `dart-flutter-patterns` | Dart and Flutter patterns |\n| `kotlin-patterns` | Kotlin patterns and best practices |\n| `kotlin-coroutines-flows` | Coroutines and Flow patterns |\n| `kotlin-exposed-patterns` | JetBrains Exposed ORM patterns |\n| `kotlin-ktor-patterns` | Ktor framework patterns |\n| `java-coding-standards` | Java coding standards for Spring Boot |\n| `jpa-patterns` | JPA/Hibernate patterns |\n| `springboot-patterns` | Spring Boot architecture and patterns |\n| `django-patterns` | Django architecture patterns |\n| `django-security` | Django security best practices |\n| `laravel-patterns` | Laravel framework patterns |\n| `laravel-security` | Laravel security practices |\n| `golang-patterns` | Idiomatic Go patterns |\n| `cpp-coding-standards` | C++ Core Guidelines-based standards |\n| `dotnet-patterns` | .NET architecture patterns |\n| `perl-patterns` | Perl patterns and best practices |\n| `rust-patterns` | Rust patterns and best practices |\n| `clickhouse-io` | ClickHouse database patterns and optimization |\n\n#### Testing\n\n| Skill | Description |\n|-------|-------------|\n| `django-tdd` | Django testing with pytest-django, TDD methodology |\n| `django-verification` | Django verification loop: migrations, linting, tests, security |\n| `laravel-tdd` | Laravel testing patterns |\n| `laravel-verification` | Laravel verification loop |\n| `springboot-tdd` | Spring Boot testing patterns |\n| `springboot-verification` | Spring Boot verification loop |\n| `golang-testing` | Go testing: table-driven, subtests, benchmarks, fuzzing |\n| `cpp-testing` | C++ testing with GoogleTest/CTest |\n| `kotlin-testing` | Kotlin testing patterns |\n| `csharp-testing` | C# testing patterns |\n| `e2e-testing` | Playwright E2E testing, Page Object Model, CI/CD |\n| `ai-regression-testing` | Regression testing for AI-assisted development |\n\n#### Business & Operations\n\n| Skill | Description |\n|-------|-------------|\n| `carrier-relationship-management` | Carrier portfolio management, freight rate negotiation, performance tracking |\n| `customer-billing-ops` | Customer billing operations |\n| `customs-trade-compliance` | Customs and trade compliance |\n| `energy-procurement` | Energy procurement strategies |\n| `enterprise-agent-ops` | Enterprise agent operations |\n| `finance-billing-ops` | Finance and billing operations |\n| `inventory-demand-planning` | Inventory and demand planning |\n| `investor-materials` | Investor materials generation |\n| `investor-outreach` | Investor outreach automation |\n| `lead-intelligence` | Lead intelligence and scoring |\n| `logistics-exception-management` | Logistics exception handling |\n| `connections-optimizer` | Network/connections optimization |\n\n#### Security & Compliance\n\n| Skill | Description |\n|-------|-------------|\n| `defi-amm-security` | DeFi AMM security patterns |\n| `llm-trading-agent-security` | LLM trading agent security |\n| `evm-token-decimals` | EVM token decimal handling |\n| `healthcare-phi-compliance` | Healthcare PHI compliance |\n| `hipaa-compliance` | HIPAA compliance patterns |\n| `ecc-tools-cost-audit` | Tool cost auditing for ECC |\n\n### 6.2 Skill Categories\n\n| # | Category | Count |\n|---|----------|-------|\n| 1 | AI & Agent | ~15 |\n| 2 | Architecture & Standards | ~25 |\n| 3 | Testing | ~12 |\n| 4 | Backend & API | ~10 |\n| 5 | Frontend & UI | ~6 |\n| 6 | Business & Operations | ~12 |\n| 7 | Security & Compliance | ~6 |\n| 8 | Content & Design | ~7 |\n| 9 | File & System | ~3 |\n| 10 | Learning & Research | ~3 |\n| 11 | Project Generation | ~3 |\n| 12 | Text Processing | ~3 |\n\n### Rating & Safety System\n\nThe awesome-reasonix project includes:\n- **Validation pipeline**: Automated checks on PRs validate format and safety. Pass → published immediately. Fail → comment explains why.\n- **Rating system**: `ratings.json` tracks community ratings. Low-rated skills hidden automatically.\n- **Quarantine**: `quarantine.json` for flagged skills.\n- **Security policy**: `SECURITY.md` + `CODE_OF_CONDUCT.md`. Skills run on your machine — read the file, check ratings, test first.\n\n---\n\n## 7. Long-Term Memory: Hindsight-Reasonix\n\n**Repo**: [github.com/houycth/Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix) \n**Stars**: ⭐0 | **Language**: Python | **Dependencies**: `hindsight-client>=0.4.22`, `mcp>=1.0.0`\n\nA **cross-session long-term memory system** adapted for Reasonix's Hook and MCP mechanisms. Uses the Hindsight API backend for vector storage and retrieval.\n\n### Architecture\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│ hindsight_hooks.py ← auto-triggered by Reasonix hooks │\n│ ├─ session-start → health check │\n│ ├─ pre-turn → recall memory → stdout injection → LLM │\n│ └─ stop → read JSONL → retain conversation turn │\n├──────────────────────────────────────────────────────────────┤\n│ hindsight_mcp.py ← optional MCP server for active search │\n│ ├─ hindsight_recall(query) → search memory │\n│ ├─ hindsight_reflect(query) → synthesize │\n│ └─ hindsight_retain(...) → manual save │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### Data Flow\n\n```\nTurn N:\n UserPromptSubmit → pre-turn:\n ① recall(user message) → stdout → Reasonix captures & injects as \n ② LLM sees memory automatically (no manual reading required)\n\nStop → stop:\n ① Get session_id from hook payload\n ② Locate session JSONL → read last user message\n ③ Strip injection block\n ④ retain(original user message + assistant reply) → store in Hindsight\n```\n\n### Key Features\n\n- **Auto-prefetch**: Pre-turn queries memory and injects via stdout. LLM sees it 100% automatically.\n- **Auto-write**: Stop hook ensures every turn is stored (without injected `` content).\n- **Active search**: LLM can use MCP tools `hindsight_recall` for deep retrieval.\n\n### Setup\n\nThree Reasonix hooks must be configured in `~/.reasonix/settings.json`:\n\n```json\n{\n \"hooks\": {\n \"SessionStart\": [{ \"command\": \"python hindsight_hooks.py session-start\", \"timeout\": 15000 }],\n \"UserPromptSubmit\": [{ \"command\": \"python hindsight_hooks.py pre-turn\", \"timeout\": 30000 }],\n \"Stop\": [{ \"command\": \"python hindsight_hooks.py stop\", \"timeout\": 30000 }]\n }\n}\n```\n\n**Note**: Requires patched Reasonix source — specifically `internal/hook/hook.go`, `internal/hook/runner.go`, `internal/control/controller.go`, and `internal/agent/save.go` — to pass `session_id` through the hook payload and strip `` from saved user messages.\n\n---\n\n## 8. Portable Toolkit: Reasonix PortaKit\n\n**Repo**: [github.com/CS-Faith/reasonix-portakit](https://github.com/CS-Faith/reasonix-portakit) \n**Stars**: ⭐8 | **Language**: PowerShell + JavaScript + Batch | **License**: MIT \n**Latest Release**: v1.0.0 — June 8, 2026\n\nMakes Reasonix **truly portable** — USB drive or cloud-sync folder, plug into any Windows machine, and all memories, conversations, skills, and MCP configurations come with you.\n\n### The Problem\n\n| Symptom | Root Cause |\n|---------|------------|\n| Memories vanish | Project memory directory hashed by workspace path; path changes → hash mismatch |\n| Conversation history blank | Session metadata stores old workspace path; mismatch → all filtered out |\n| Skills / MCP broken | `config.json` hardcodes MCP paths with old drive letter (E:\\Reasonix) |\n| Nothing reads from here | Reasonix defaults to `C:\\Users\\...`, never looks at current directory |\n\n### How It Works\n\n```\n双击 启动Reasonix.bat\n │\n ├─ 1. Auto-detect current path (any drive, any folder)\n ├─ 2. Tell Reasonix: \"Your data is here, not in C:\\Users\\...\"\n ├─ 3. SHA1(path) → project hash → create dirs; auto-rename/merge old hash dirs\n ├─ 4. Merge local machine's Reasonix data (newer-only, no overwrites)\n ├─ 5. Patch config.json — old paths → current path\n ├─ 6. Repair all session metadata (strip BOM + fix JSON escaping + align workspace)\n └─ 7. Launch Reasonix\n```\n\n### Files\n\n| File | Size | Purpose |\n|------|------|---------|\n| `启动Reasonix.bat` | 0.6 KB | Entry point |\n| `_patch_config.ps1` | 6 KB | Core engine |\n| `_fix_sessions.js` | 2.7 KB | Session metadata repair |\n| `PortaKit-核心规则.md` | 2.4 KB | Core rules for Reasonix to remember portable setup |\n\n### Usage\n\n1. Ensure Reasonix has been launched at least once (API key configured)\n2. Copy PortaKit files into the Reasonix directory (next to `reasonix-desktop.exe`)\n3. Always launch via `启动Reasonix.bat` — never double-click `reasonix-desktop.exe` directly\n4. (Recommended) Import `PortaKit-核心规则.md` into Reasonix so it learns the portable mechanism\n5. After one bat launch, the entire folder becomes fully portable:\n - USB drive → any Windows PC → double-click bat → everything intact\n - Sync folder (OneDrive, Dropbox, etc.) → new PC sync → double-click bat → local data merged in\n\n---\n\n## 9. Protocol Landscape: MCP / ACP / CDP\n\n### MCP (Model Context Protocol) — ✅ Well Established\n\nMCP is the **dominant extension protocol** in the Reasonix ecosystem. Reasonix is a first-class MCP client with three transport types:\n\n- **`stdio`** (default) — launches a local subprocess; one JSON message per line over stdin/stdout\n- **`http`** (Streamable HTTP) — remote server at a URL; POST with optional SSE stream\n- **`sse`** — legacy 2024-11-05 transport; recognized but deferred (deprecated upstream)\n\nTools surface to the model as `mcp____`; a tool declaring MCP's `readOnlyHint: true` joins parallel dispatch. MCP prompts appear as `/mcp____` slash commands; MCP resources are pulled in via `@:`.\n\n**`.mcp.json` drop-in**: Reasonix reads a project-root `.mcp.json` using Claude Code's exact `mcpServers` schema — an existing Claude Code MCP config works in Reasonix unchanged.\n\n**`/mcp`** — Lists connected servers and what each exposes.\n\n| Integration | Type | MCP Tools |\n|-------------|------|-----------|\n| reasonix-mcp-server | Claude Code ↔ Reasonix | `reasonix_run`, `reasonix_doctor` |\n| reasonix-bridge | Codex ↔ DeepSeek | `execute_task`, `plan_task`, `orchestrate_task`, etc. |\n| opencode-reasonix-mcp | OpenCode ↔ Reasonix | Task dispatch |\n| Hindsight-Reasonix (MCP mode) | Memory retrieval | `hindsight_recall`, `hindsight_reflect`, `hindsight_retain` |\n| codegraph (built-in) | Semantic code index | Daemon process with MCP interface |\n\nReasonix has **native MCP support** built into its TOML config:\n\n```toml\n[[plugins]]\nname = \"hindsight\"\ncommand = \"python\"\nargs = [\"/path/to/hindsight_mcp.py\"]\n\n# Remote MCP over Streamable HTTP\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n```\n\nThe reference MCP stdio plugin (`cmd/reasonix-plugin-example`) ships as a runnable example with `echo`, `wordcount`, a `review` prompt, and a `style-guide` resource.\n\n### ACP (Agent Communication Protocol) — ✅ Implemented (v1.5.0+)\n\n**Confirmed in the v1.5.0 changelog** (Jun 10, 2026):\n\n```\nfix(cli): disable codegraph in ACP session config test (#3663)\n```\n\nThis confirms Reasonix has a native **ACP (Agent Communication Protocol)** implementation. ACP sessions run with codegraph indexing disabled to avoid daemon interference, which means ACP is a distinct session mode with its own constraints.\n\nOther ACP-adjacent projects and patterns in the ecosystem:\n\n- **[collab-cli](https://github.com/Staryinsang0910-star/collab-cli)** (⭐7) — Universal collaboration protocol + CLI for multi-agent LLM teams. Supports Claude Code, Reasonix, Codex, WorkBuddy, and Cursor as peers in a shared agent mesh. Defines how heterogeneous agents discover each other, delegate work, share context, and synchronize results.\n- **reasonix-bridge** — The \"Scientist Team\" pattern defines role-based inter-agent communication (Dijkstra ↔ Hopper ↔ Turing, etc.) with interface contract extraction between parallel agents.\n- **council skill** (awesome-reasonix) — Multi-agent discussion and decision-making pattern.\n- **Two-model collaboration** (Reasonix native) — The Coordinator runs an executor and planner in separate cache-stable sessions, handing off structured plans between them — a form of intra-tool agent communication.\n\n### CDP (Context Data Protocol) — 🔶 Emerging\n\nNo project explicitly branded \"CDP\" was found. The closest implementations:\n\n- **[Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix)** — Provides cross-session context persistence via MCP tools (`hindsight_recall`, `hindsight_retain`, `hindsight_reflect`). Functions as a context data layer that survives across sessions.\n- **Reasonix's built-in memory** (`.reasonix/memory.md`) — Persistent across sessions, loaded at startup.\n- **Reasonix's semantic index** — Codebase-wide context for faster file discovery.\n\n---\n\n## 10. Use Cases & Workflows\n\n### 10.1 Low-Cost AI Coding (Daily Development)\n\n**Stack**: Reasonix core (DeepSeek V4 Flash default)\n\nThe primary use case. Point Reasonix at a codebase, describe what you want, and it reads files, writes code, runs commands, and iterates. Costs $0.03–$0.15 per typical session thanks to 99.8% prefix cache hits.\n\n**Best for**: Feature development, bug fixing, refactoring, code review, test generation.\n\n```bash\ncd your-project\nreasonix chat\n> Add input validation to the signup form. Email must be valid, password minimum 8 chars.\n> Also add a confirm password field that must match.\n```\n\n### 10.2 Hybrid Claude Code + DeepSeek Workflow\n\n**Stack**: Claude Code + reasonix-mcp-server + Reasonix (DeepSeek)\n\nUse Claude Code for high-level reasoning, architecture design, and complex orchestration. Route the heavy file-editing work to Reasonix/DeepSeek via MCP to save ~80% on token costs.\n\n**Best for**: Teams already using Claude Code who want to reduce API spend without sacrificing quality.\n\n```\nClaude Code (orchestrator)\n → reasonix_run(\"refactor auth module to use JWT\")\n → Reasonix/DeepSeek (executor, $0.05)\n → Claude reviews the diff\n → reasonix_run(\"add tests for the new JWT module\")\n```\n\n### 10.3 Multi-Agent Parallel Development\n\n**Stack**: Codex + reasonix-bridge + Scientist Team\n\nCodex delegates complex multi-module tasks to reasonix-bridge, which automatically decomposes the work and assigns parallel sub-agents (Dijkstra for backend, Hopper for frontend, Turing for database, etc.).\n\n**Best for**: Full-stack feature development, multi-module refactors, greenfield project scaffolding.\n\n```\nCodex → execute_task(\"Build a blog system with backend API, frontend UI, and database\")\n → Bridge auto-detects complexity → multi-agent mode\n ├─ Dijkstra (backend): API routes, controllers, services\n ├─ Hopper (frontend): React components, pages, routing\n └─ Turing (database): schema, migrations, queries\n → Parallel execution, interface contracts extracted\n → All results merged\n```\n\n### 10.4 CI/CD Automation\n\n**Stack**: `reasonix run \"task\"` (non-interactive, piped input)\n\nThe `run` mode is designed for automation. It accepts piped input, runs non-interactively, and exits with appropriate status codes.\n\n**Best for**: Automated code review, test generation, documentation updates, security scanning.\n\n```bash\n# In CI pipeline:\ngit diff HEAD~1 | reasonix run \"review these changes for security issues\"\nreasonix run \"generate documentation for the ./api directory\"\nreasonix run \"update CHANGELOG.md based on git log since last tag\"\n```\n\n### 10.5 Cross-Session Persistent Memory\n\n**Stack**: Hindsight-Reasonix (hooks + MCP)\n\nEvery conversation turn is automatically recalled (pre-turn) and retained (stop). Memory persists across sessions without manual management. The optional MCP server enables active search.\n\n**Best for**: Long-running projects where institutional knowledge must accumulate over weeks/months; onboarding new agents to existing codebases.\n\n```\nSession 1: \"Set up the project structure\"\n → Hindsight retains: architecture decisions, tech stack, conventions\n\nSession 2 (next day): \"Add the payment module\"\n → Hindsight recalls previous session's context\n → LLM sees it automatically as \n → No need to re-explain the project structure\n```\n\n### 10.6 Portable Development Environment\n\n**Stack**: Reasonix PortaKit + USB drive / cloud sync\n\nCarry your entire Reasonix environment (memories, skills, MCP config, conversations) on a USB drive. Plug into any Windows machine, double-click the bat file, and everything is exactly where you left it.\n\n**Best for**: Developers who work across multiple machines (work PC, home PC, laptop); freelancers; educators.\n\n### 10.7 Community Skill Extensions\n\n**Stack**: awesome-reasonix skills\n\nExtend Reasonix with 80+ community-contributed skills covering everything from file organization to multi-agent councils. New skills can be installed in seconds.\n\n**Best for**: Customizing Reasonix for specific workflows, languages, or domains.\n\n```bash\n# Install\ncurl -o .reasonix/skills/deep-research.md https://raw.githubusercontent.com/.../deep-research.md\n# Use\n> /skill deep-research \"Compare the top 5 Rust web frameworks\"\n```\n\n### 10.8 Multi-Agent Collaboration Protocol\n\n**Stack**: collab-cli\n\nUse multiple different agent systems (Claude Code + Reasonix + Codex + Cursor) in a coordinated mesh, each playing to its strengths.\n\n**Best for**: Teams using heterogeneous agent tooling; complex pipelines requiring specialized agents.\n\n---\n\n## 11. Appendix: Quick-Reference Links\n\n### Core\n\n| Resource | URL |\n|----------|-----|\n| Reasonix Main Repo (v1.0 Go) | [github.com/esengine/DeepSeek-Reasonix](https://github.com/esengine/deepseek-reasonix) |\n| Official Website | [reasonix.homes](https://reasonix.homes) |\n| DeepSeek Official Docs | [api-docs.deepseek.com/.../reasonix](https://api-docs.deepseek.com/quick_start/agent_integrations/reasonix) |\n| npm Package | [npmjs.com/package/reasonix](https://www.npmjs.com/package/reasonix) |\n| brew Formula | `brew install esengine/reasonix/reasonix` |\n| Architecture Docs | [esengine.github.io/DeepSeek-Reasonix](https://esengine.github.io/DeepSeek-Reasonix/) |\n\n### Official Docs (in repo)\n\n| Document | URL |\n|----------|-----|\n| GUIDE.md (Configuration & Usage) | [github.com/.../docs/GUIDE.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md) |\n| GUIDE.zh-CN.md (简体中文指南) | [github.com/.../docs/GUIDE.zh-CN.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.zh-CN.md) |\n| SPEC.md (Engineering Contract) | [github.com/.../docs/SPEC.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SPEC.md) |\n| CHECKPOINTS.md (Rewind Design) | [github.com/.../docs/CHECKPOINTS.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/CHECKPOINTS.md) |\n| MIGRATING.md (0.x → 1.0) | [github.com/.../docs/MIGRATING.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/MIGRATING.md) |\n| REASONIX.md (Project Memory) | [github.com/.../REASONIX.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/REASONIX.md) |\n| SESSION_REFERENCE_ARCHITECTURE.md | [github.com/.../docs/SESSION_REFERENCE_ARCHITECTURE.md](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/SESSION_REFERENCE_ARCHITECTURE.md) |\n\n### Community\n\n| Resource | URL |\n|----------|-----|\n| Discord (bilingual) | [discord.gg/XF78rEME2D](https://discord.gg/XF78rEME2D) |\n| GitHub Issues | [github.com/.../issues](https://github.com/esengine/deepseek-reasonix/issues) (266 open) |\n| GitHub Discussions | [github.com/.../discussions](https://github.com/esengine/deepseek-reasonix/discussions) |\n| Pull Requests | [github.com/.../pulls](https://github.com/esengine/deepseek-reasonix/pulls) (48 open) |\n\n### MCP Bridges\n\n| Resource | URL |\n|----------|-----|\n| reasonix-mcp-server (Claude Code ←→ Reasonix) | [github.com/Belveth02/reasonix-mcp-server](https://github.com/Belveth02/reasonix-mcp-server) |\n| reasonix-bridge (Codex ←→ DeepSeek) | [github.com/OwlCodeTech/reasonix-bridge](https://github.com/OwlCodeTech/reasonix-bridge) |\n| opencode-reasonix-mcp | [github.com/StarWHAT-BUG/opencode-reasonix-mcp](https://github.com/StarWHAT-BUG/opencode-reasonix-mcp) |\n| collab-cli (Universal Protocol) | [github.com/Staryinsang0910-star/collab-cli](https://github.com/Staryinsang0910-star/collab-cli) |\n\n### Skills & Community\n\n| Resource | URL |\n|----------|-----|\n| awesome-reasonix (Hub) | [github.com/hikari-2424/awesome-reasonix](https://github.com/hikari-2424/awesome-reasonix) |\n| Skills Site (Browse) | [hikari-2424.github.io/awesome-reasonix-site](https://hikari-2424.github.io/awesome-reasonix-site/) |\n| Full Registry JSON | [github.com/.../registry.json](https://github.com/hikari-2424/awesome-reasonix/blob/main/registry.json) |\n\n### Memory & Portability\n\n| Resource | URL |\n|----------|-----|\n| Hindsight-Reasonix (Memory) | [github.com/houycth/Hindsight-Reasonix](https://github.com/houycth/Hindsight-Reasonix) |\n| Reasonix PortaKit | [github.com/CS-Faith/reasonix-portakit](https://github.com/CS-Faith/reasonix-portakit) |\n\n### Guides & Comparisons\n\n| Resource | URL |\n|----------|-----|\n| Complete Guide (aimadetools) | [aimadetools.com/blog/reasonix-complete-guide](https://www.aimadetools.com/blog/reasonix-complete-guide) |\n| Setup Guide | [aimadetools.com/blog/how-to-use-reasonix](https://www.aimadetools.com/blog/how-to-use-reasonix) |\n| Reasonix vs Claude Code | [aimadetools.com/...](https://www.aimadetools.com/blog/reasonix-vs-claude-code) |\n| Reasonix vs Aider | [aimadetools.com/...](https://www.aimadetools.com/blog/reasonix-vs-aider-for-deepseek) |\n\n### Distribution\n\n| Channel | Details |\n|---------|---------|\n| npm | `npm i -g reasonix` (pulls prebuilt Go binary) |\n| Homebrew | `brew install esengine/reasonix/reasonix` |\n| Prebuilt Archives | 6 targets: darwin/linux/windows × amd64/arm64 + SHA256SUMS |\n| Debian | `.deb` package built on release |\n| Source | `make build` (→ `bin/reasonix`) / `make cross` (→ `dist/`) |\n| Windows Signing | [SignPath Foundation](https://signpath.io/) (free certificate) |\n\n### Contributors & Acknowledgments\n\nTop contributors (alphabetical, by commit count + code volume): **ctharvey**, **dimasd-angga**, **Evan-Pycraft**, **ForeverYoungPp**, **GTC2080**, **kabaka9527**, **lisniuse**, **wade19990814-hue**, **wviana**.\n\nLogo by **Bernardxu123**. Community promotion on XiaoHongShu (小红书) by **AIGC Link**.\n\n### Donations\n\nReasonix is MIT-licensed and free. Donations stay \"a coffee, not a contract\" — they don't buy feature priority or change issue triage. [PayPal: paypal.me/yuhuahui](https://paypal.me/yuhuahui) · WeChat Pay (QR in README).\n\n---\n\n> **Document**: Updated Jun 2026 from primary source `esengine/DeepSeek-Reasonix` (main-v2, v1.5.0, Jun 10 2026), community GitHub repos, DeepSeek API docs, npm, aimadetools, reasonix.homes. \n> **Note**: The \"Reasonix\" name also refers to [reasonixos.com](https://reasonixos.com/about) — an \"Integrated Intelligence Company\" (AI-powered enterprise decision systems / AI Growth OS for marketing). This document covers only the DeepSeek-native coding agent ecosystem, not the enterprise platform.\n"}}, "reasonix.example.toml": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785745339.233106, "n": 10064, "h": "3e7807a2631f801d", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults.\n# Fields marked user/global only are not overridden by ./reasonix.toml.\n# Provider entries name secrets via api_key_env; saved key values live in\n# Reasonix's global ~/.reasonix/.env. Never put API key values here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit\nplanner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # user-level only; ignored in project reasonix.toml\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# Route background jobs (compaction summaries, image understanding, web-page\n# reading) to a cheap/fast provider, keeping the main model's context budget\n# for real reasoning. Unset = use the main provider for that job.\n#\n# [agent.auxiliary]\n# compression = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n# vision = { provider = \"ollamacloud-vision\", model = \"gemini-3-flash-preview\" }\n# web_extract = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n\n[serve]\n# Authentication for the HTTP serve frontend (reasonix serve).\n# auth_mode = \"none\" # none | token | password\n# token = \"\" # pre-shared token for auth_mode = \"token\" (auto-generated if empty)\n# password_hash = \"\" # bcrypt hash for auth_mode = \"password\". Generate with: reasonix serve --hash-password \n# behind_proxy = false # set true when behind a trusted reverse proxy (nginx, Caddy, etc.) that sets X-Forwarded-* headers\n"}, "4fe39d3092146290b1c60b2a77f869909f77f74e": {"t": 1785750391.7769725, "n": 10064, "h": "3e7807a2631f801d", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults.\n# Fields marked user/global only are not overridden by ./reasonix.toml.\n# Provider entries name secrets via api_key_env; saved key values live in\n# Reasonix's global ~/.reasonix/.env. Never put API key values here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit\nplanner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # user-level only; ignored in project reasonix.toml\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# Route background jobs (compaction summaries, image understanding, web-page\n# reading) to a cheap/fast provider, keeping the main model's context budget\n# for real reasoning. Unset = use the main provider for that job.\n#\n# [agent.auxiliary]\n# compression = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n# vision = { provider = \"ollamacloud-vision\", model = \"gemini-3-flash-preview\" }\n# web_extract = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n\n[serve]\n# Authentication for the HTTP serve frontend (reasonix serve).\n# auth_mode = \"none\" # none | token | password\n# token = \"\" # pre-shared token for auth_mode = \"token\" (auto-generated if empty)\n# password_hash = \"\" # bcrypt hash for auth_mode = \"password\". Generate with: reasonix serve --hash-password \n# behind_proxy = false # set true when behind a trusted reverse proxy (nginx, Caddy, etc.) that sets X-Forwarded-* headers\n"}, "535993a271e2d97864cbf2406ffb0f71fb317d80": {"t": 1785750392.8677027, "n": 9593, "h": "ff79115e1f3885ff", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults.\n# Fields marked user/global only are not overridden by ./reasonix.toml.\n# Provider entries name secrets via api_key_env; saved key values live in\n# Reasonix's global ~/.reasonix/.env. Never put API key values here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit\nplanner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # user-level only; ignored in project reasonix.toml\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n\n[serve]\n# Authentication for the HTTP serve frontend (reasonix serve).\n# auth_mode = \"none\" # none | token | password\n# token = \"\" # pre-shared token for auth_mode = \"token\" (auto-generated if empty)\n# password_hash = \"\" # bcrypt hash for auth_mode = \"password\". Generate with: reasonix serve --hash-password \n# behind_proxy = false # set true when behind a trusted reverse proxy (nginx, Caddy, etc.) that sets X-Forwarded-* headers\n"}, "7e7c7eda097114c87b2247e036c0df8311bd7c62": {"t": 1785750395.023195, "n": 9119, "h": "b08f6c7b116579bb", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults.\n# Fields marked user/global only are not overridden by ./reasonix.toml.\n# Provider entries name secrets via api_key_env; saved key values live in\n# Reasonix's global ~/.reasonix/.env. Never put API key values here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit\nplanner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # user-level only; ignored in project reasonix.toml\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "66a016261b533bac73c1afc9eaeef1792b9ebe36": {"t": 1785750396.1388407, "n": 9528, "h": "45e820fdc7666b28", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Fields marked user/global only are not overridden by ./reasonix.toml.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit\nplanner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # user-level only; ignored in project reasonix.toml\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# Route background jobs (compaction summaries, image understanding, web-page\n# reading) to a cheap/fast provider, keeping the main model's context budget\n# for real reasoning. Unset = use the main provider for that job.\n#\n# [agent.auxiliary]\n# compression = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n# vision = { provider = \"ollamacloud-vision\", model = \"gemini-3-flash-preview\" }\n# web_extract = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "9ada14176629b1d59d7ed78446951b2bb5954904": {"t": 1785750397.250817, "n": 9057, "h": "fc0e4a8eec397190", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Fields marked user/global only are not overridden by ./reasonix.toml.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit\nplanner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # user-level only; ignored in project reasonix.toml\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "01d9b1730edf7be9f130a3296f8ecdc3ae3bc742": {"t": 1785750398.3349428, "n": 8949, "h": "288fb330f6343db8", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # user-level only; ignored in project reasonix.toml\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "62180a99d685f6460b9afa96d1277323e7f0b4be": {"t": 1785750399.4617095, "n": 9042, "h": "333dfedd12395fab", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Fields marked user/global only are not overridden by ./reasonix.toml.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # user/global only; executor tool-call rounds; 0 = no limit\nplanner_max_steps = 0 # user/global only; planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "2ff0c3de3f6ca80b583929a8f6d822cff042896f": {"t": 1785750400.7010393, "n": 9405, "h": "9533de61d4f58e0a", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# Route background jobs (compaction summaries, image understanding, web-page\n# reading) to a cheap/fast provider, keeping the main model's context budget\n# for real reasoning. Unset = use the main provider for that job.\n#\n# [agent.auxiliary]\n# compression = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n# vision = { provider = \"ollamacloud-vision\", model = \"gemini-3-flash-preview\" }\n# web_extract = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "d9e453a1bedd675c226660cab033da35421b141d": {"t": 1785750401.8095355, "n": 8934, "h": "1c0af85fc709aa19", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"deepseek-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when\n# a model needs its own base_url / context_window / price.\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other OpenAI-compatible providers don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"openai-compatible-custom\"\n# kind = \"openai\"\n# base_url = \"https://api.example.com/v1\"\n# model = \"example-reasoning-model\"\n# api_key_env = \"EXAMPLE_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "9fb4088d4c2b3b75ecc184dc774ba7b38f08b816": {"t": 1785750403.008145, "n": 10191, "h": "fa0fcaec57c57f6b", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# Route background jobs (compaction summaries, image understanding, web-page\n# reading) to a cheap/fast provider, keeping the main model's context budget\n# for real reasoning. Unset = use the main provider for that job.\n#\n# [agent.auxiliary]\n# compression = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n# vision = { provider = \"ollamacloud-vision\", model = \"gemini-3-flash-preview\" }\n# web_extract = { provider = \"deepseek\", model = \"deepseek-v4-flash\" }\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\nprice = { cache_hit = 0.07, input = 0.70, output = 2.10, currency = \"¥\" } # per 1M tokens\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "b02a1f65abee1a3607ce9a3525457ad2ccd4737c": {"t": 1785750404.1263072, "n": 9720, "h": "08a47eb64107ad51", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench|creation; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\nprice = { cache_hit = 0.07, input = 0.70, output = 2.10, currency = \"¥\" } # per 1M tokens\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "655c72ad9b33266c8d6e94dd3d030abf8380ee05": {"t": 1785750405.721649, "n": 9711, "h": "da87a437f3b29887", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\nprice = { cache_hit = 0.07, input = 0.70, output = 2.10, currency = \"¥\" } # per 1M tokens\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "dc301261a1d225edc8a47c5c253d9411a9f1c10d": {"t": 1785750407.3903832, "n": 10276, "h": "e66c8ee3a50ad734", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"auto\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\nprice = { cache_hit = 0.07, input = 0.70, output = 2.10, currency = \"¥\" } # per 1M tokens\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "1a87cd4cfde4b7ca269dcb893534a78bb4c90877": {"t": 1785750408.9575412, "n": 10277, "h": "ccee7f9348611d91", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"light\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\n# Official DeepSeek providers seed RMB prices by default.\n# Set prices explicitly only when you want to pin a currency/rate.\n# prices = { \"deepseek-v4-flash\" = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" }, \"deepseek-v4-pro\" = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\nprice = { cache_hit = 0.07, input = 0.70, output = 2.10, currency = \"¥\" } # per 1M tokens\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "22b7c8dd13363d8bc5d9f446a27046f06d5f7dac": {"t": 1785750410.540781, "n": 9976, "h": "4daf5858b659d71e", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"light\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprices = { \"deepseek-v4-flash\" = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = \"$\" }, \"deepseek-v4-pro\" = { cache_hit = 0.003625, input = 0.435, output = 0.87, currency = \"$\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n[tools.background_jobs]\nstalled_warning_seconds = 900 # warn once per background job after this many quiet seconds; 0 disables\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "a029618da9916a84d7289efa722405373a68062a": {"t": 1785750412.014084, "n": 9846, "h": "185ea68bf2c719a5", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"light\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprices = { \"deepseek-v4-flash\" = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = \"$\" }, \"deepseek-v4-pro\" = { cache_hit = 0.003625, input = 0.435, output = 0.87, currency = \"$\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "a6aae98f8286ff45cfb9f98682b40f266ff37fd0": {"t": 1785750413.1404643, "n": 9813, "h": "5df69889e5ec03e6", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"light\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "a58532f0217587ec9ca9b73bf05b257442067fd1": {"t": 1785750415.5549965, "n": 9589, "h": "4a05bf0b3fb44dd7", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "c376ec76e2ff0836ddb0e346724eb155265bbeb7": {"t": 1785750416.7029595, "n": 9532, "h": "ca6e06a54a98089a", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "f6d8cce7eb7294a4a9e4a5c6492cb0941112e545": {"t": 1785750417.7916188, "n": 9414, "h": "cfa9fb1b512368fe", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "040126d224b297e561fecea3c330bcfd02b8aecc": {"t": 1785750419.3556662, "n": 9345, "h": "efa5b8ae95c2ca0c", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "28bedc6b7ffe167c6325863a2d3ff0034f7bb7d3": {"t": 1785750420.475531, "n": 9227, "h": "bbc0c625700e3514", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "0d248f7d21dd88ef397faaac653a6bc9fabf3058": {"t": 1785750421.5429895, "n": 8098, "h": "e30be55feedc9416", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "304e1152cddce0b025861b5accc77c52b20aee04": {"t": 1785750423.6476557, "n": 7960, "h": "f246f4a71788b8e9", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "bf0c67db5a3d651413e3234029fc81865caa932a": {"t": 1785750425.3337746, "n": 7732, "h": "7618101373104f4c", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "38e4c5a66d45daacca41e3b4ee7b84fa91aea478": {"t": 1785750426.4207373, "n": 7650, "h": "d71f834fcde5e636", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "121f7085e4c490cd21e31a0f058117b78965e221": {"t": 1785750427.5102365, "n": 7553, "h": "1acdbcb37a31a43c", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "3cec9768f47d9c12435a42dca7618e75155e536a": {"t": 1785750428.6520615, "n": 8002, "h": "534612846feef0e4", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background. Choose tier the same way as ordinary MCP servers.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n# tier = \"lazy\" # lazy | background | eager\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "c0bda7ce577f66ebfb31688d98feae3e0b8a5011": {"t": 1785750429.7445996, "n": 7741, "h": "80b02d96296da9bf", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background. Choose tier the same way as ordinary MCP servers.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n# tier = \"lazy\" # lazy | background | eager\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "a4db571df079b643cd1660a79f04251d07ef0692": {"t": 1785750430.8370457, "n": 7087, "h": "e0cb8da180f98bd2", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background. Choose tier the same way as ordinary MCP servers.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n# tier = \"lazy\" # lazy | background | eager\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}}, "benchmarks/context-maintenance-e2e/main.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785745628.3175924, "n": 8715, "h": "5774f68b2ef3c465", "c": "// Drives the context-maintenance E2E scenarios against the real DeepSeek API:\n// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/provider\"\n\t_ \"reasonix/internal/provider/openai\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n)\n\nconst (\n\tmodel = \"deepseek-v4-flash\"\n\tbaseURL = \"https://api.deepseek.com\"\n\tfatResults = 20\n\tfatBytes = 12_000\n)\n\nfunc prov() provider.Provider {\n\tkey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tif key == \"\" {\n\t\tfmt.Fprintln(os.Stderr, \"DEEPSEEK_API_KEY not set\")\n\t\tos.Exit(1)\n\t}\n\tp, err := provider.New(\"openai\", provider.Config{Name: \"e2e\", BaseURL: baseURL, Model: model, APIKey: key})\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\treturn p\n}\n\nfunc fakeGoFile(nonce string, i, size int) string {\n\tvar b strings.Builder\n\tfmt.Fprintf(&b, \"// module %s file%02d\\npackage stress\\n\\n\", nonce, i)\n\tline := 0\n\tfor b.Len() < size {\n\t\tfmt.Fprintf(&b, \"func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\\n\", nonce, i, line, line+3, line*7)\n\t\tline++\n\t}\n\treturn b.String()\n}\n\nfunc seedSession(nonce string) *agent.Session {\n\ts := agent.NewSession(\"You are a terse coding agent reviewing a Go codebase.\")\n\ts.Add(provider.Message{Role: provider.RoleUser, Content: \"Review every file in module \" + nonce + \" one by one. Keep notes short.\"})\n\tfor i := 0; i < fatResults; i++ {\n\t\tid := fmt.Sprintf(\"c%02d\", i)\n\t\tname := fmt.Sprintf(\"src/file%02d.go\", i)\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: \"read_file\", Arguments: fmt.Sprintf(`{\"path\":%q}`, name)}}})\n\t\ts.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: \"read_file\", Content: fakeGoFile(nonce, i, fatBytes)})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf(\"Reviewed %s.\", name)})\n\t}\n\treturn s\n}\n\n// oneShot appends a user message and runs a single completion, returning usage.\nfunc oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {\n\treq := provider.Request{\n\t\tMessages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),\n\t\tMaxTokens: 32,\n\t}\n\tch, err := p.Stream(context.Background(), req)\n\tif err != nil {\n\t\treturn provider.Usage{}, err\n\t}\n\tvar u provider.Usage\n\tfor c := range ch {\n\t\tswitch c.Type {\n\t\tcase provider.ChunkUsage:\n\t\t\tu = *c.Usage\n\t\tcase provider.ChunkError:\n\t\t\treturn u, c.Err\n\t\t}\n\t}\n\treturn u, nil\n}\n\ntype meta struct {\n\tSeededAt time.Time `json:\"seeded_at\"`\n\tNonces map[string]string `json:\"nonces\"`\n\tSeedUse map[string]provider.Usage `json:\"seed_usage\"`\n}\n\nfunc seed(dir string) {\n\tp := prov()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tm := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}\n\tfor _, arm := range []string{\"pruned\", \"control\"} {\n\t\tnonce := fmt.Sprintf(\"%s%d\", arm, time.Now().UnixNano()%1_000_000)\n\t\ts := seedSession(nonce)\n\t\tu, err := oneShot(p, s.Snapshot(), \"How many files have you reviewed so far? Reply with just the number.\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"seed %s: %v\\n\", arm, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.Save(filepath.Join(dir, arm+\".jsonl\")); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tm.Nonces[arm] = nonce\n\t\tm.SeedUse[arm] = u\n\t\tfmt.Printf(\"seeded %-7s prompt=%d hit=%d miss=%d\\n\", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tb, _ := json.MarshalIndent(m, \"\", \" \")\n\tif err := os.WriteFile(filepath.Join(dir, \"meta.json\"), b, 0o644); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc resume(dir string) {\n\tp := prov()\n\traw, err := os.ReadFile(filepath.Join(dir, \"meta.json\"))\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tvar m meta\n\tif err := json.Unmarshal(raw, &m); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tidle := time.Since(m.SeededAt).Round(time.Minute)\n\n\tout := map[string]provider.Usage{}\n\tfor _, arm := range []string{\"pruned\", \"control\"} {\n\t\ts, err := agent.LoadSession(filepath.Join(dir, arm+\".jsonl\"))\n\t\tif err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tprunes := 0\n\t\tif arm == \"pruned\" {\n\t\t\ta := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, \"archive\")}, event.Discard)\n\t\t\tst, err := a.PruneStaleToolResults()\n\t\t\tif err != nil {\n\t\t\t\tfmt.Fprintln(os.Stderr, \"prune:\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tprunes = st.Results\n\t\t}\n\t\tu, err := oneShot(p, s.Snapshot(), \"Which file did you review first? Reply with just the path.\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"resume %s: %v\\n\", arm, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tout[arm] = u\n\t\tfmt.Printf(\"resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\\n\", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tb, _ := json.MarshalIndent(map[string]any{\"idle\": idle.String(), \"resume_usage\": out}, \"\", \" \")\n\tif err := os.WriteFile(filepath.Join(dir, fmt.Sprintf(\"resume-%d.json\", time.Now().Unix())), b, 0o644); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\n\tc, pr := out[\"control\"], out[\"pruned\"]\n\tif c.CacheMissTokens > 0 {\n\t\tfmt.Printf(\"\\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\\n\",\n\t\t\tc.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))\n\t}\n}\n\n// comprehension checks that the agent re-reads a file behind a prune placeholder\n// instead of hallucinating: the answer is a number that exists only in the file.\nfunc comprehension(trials int) {\n\tp := prov()\n\tpass := 0\n\tfor t := 0; t < trials; t++ {\n\t\tdir, err := os.MkdirTemp(\"\", \"cm-e2e-\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tsecret := fmt.Sprintf(\"%d\", 1000+time.Now().UnixNano()%9000)\n\t\tcontent := \"package cfg\\n\\n// retention floor, milliseconds\\nconst cacheRetentionFloor = \" + secret + \"\\n\" + strings.Repeat(\"// padding line filler for prune eligibility\\n\", 400)\n\t\tif err := os.WriteFile(filepath.Join(dir, \"config.go\"), []byte(content), 0o644); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\ts := agent.NewSession(\"You are a terse coding agent. Use tools when you need file contents.\")\n\t\ts.Add(provider.Message{Role: provider.RoleUser, Content: \"Read config.go and note its constants.\"})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: \"r1\", Name: \"read_file\", Arguments: `{\"path\":\"config.go\"}`}}})\n\t\ts.Add(provider.Message{Role: provider.RoleTool, ToolCallID: \"r1\", Name: \"read_file\", Content: content})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Noted the constants in config.go.\"})\n\t\tfor i := 0; i < 4; i++ {\n\t\t\ts.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(\"ack %d\", i)})\n\t\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\t\t}\n\n\t\treg := tool.NewRegistry()\n\t\tfor _, tl := range (builtin.Workspace{Dir: dir}).Tools(\"read_file\") {\n\t\t\treg.Add(tl)\n\t\t}\n\t\ta := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)\n\t\tst, err := a.PruneStaleToolResults()\n\t\tif err != nil || st.Results == 0 {\n\t\t\tfmt.Fprintf(os.Stderr, \"trial %d: prune did not fire (st=%+v err=%v)\\n\", t, st, err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\terr = a.Run(context.Background(), \"What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.\")\n\t\treRead, answered := false, false\n\t\tfor _, msg := range s.Snapshot() {\n\t\t\tif msg.Role == provider.RoleTool && strings.Contains(msg.Content, \"cacheRetentionFloor = \"+secret) {\n\t\t\t\treRead = true\n\t\t\t}\n\t\t\tif msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {\n\t\t\t\tanswered = true\n\t\t\t}\n\t\t}\n\t\tok := err == nil && reRead && answered\n\t\tif ok {\n\t\t\tpass++\n\t\t}\n\t\tfmt.Printf(\"trial %d: re-read=%v answered=%v err=%v\\n\", t, reRead, answered, err)\n\t\tos.RemoveAll(dir)\n\t}\n\tfmt.Printf(\"\\ncomprehension: %d/%d passed\\n\", pass, trials)\n\tif pass < trials {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc main() {\n\tdir := flag.String(\"dir\", \"benchmarks/context-maintenance-e2e/run\", \"state directory for seed/resume\")\n\ttrials := flag.Int(\"trials\", 5, \"comprehension trials\")\n\tflag.Parse()\n\tswitch flag.Arg(0) {\n\tcase \"seed\":\n\t\tseed(*dir)\n\tcase \"resume\":\n\t\tresume(*dir)\n\tcase \"comprehension\":\n\t\tcomprehension(*trials)\n\tdefault:\n\t\tfmt.Fprintln(os.Stderr, \"usage: context-maintenance-e2e [seed|resume|comprehension]\")\n\t\tos.Exit(1)\n\t}\n}\n"}, "3025f5af2a8f4364eea52db323e402edc27e84a9": {"t": 1785750392.7477672, "n": 8715, "h": "5774f68b2ef3c465", "c": "// Drives the context-maintenance E2E scenarios against the real DeepSeek API:\n// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/provider\"\n\t_ \"reasonix/internal/provider/openai\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n)\n\nconst (\n\tmodel = \"deepseek-v4-flash\"\n\tbaseURL = \"https://api.deepseek.com\"\n\tfatResults = 20\n\tfatBytes = 12_000\n)\n\nfunc prov() provider.Provider {\n\tkey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tif key == \"\" {\n\t\tfmt.Fprintln(os.Stderr, \"DEEPSEEK_API_KEY not set\")\n\t\tos.Exit(1)\n\t}\n\tp, err := provider.New(\"openai\", provider.Config{Name: \"e2e\", BaseURL: baseURL, Model: model, APIKey: key})\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\treturn p\n}\n\nfunc fakeGoFile(nonce string, i, size int) string {\n\tvar b strings.Builder\n\tfmt.Fprintf(&b, \"// module %s file%02d\\npackage stress\\n\\n\", nonce, i)\n\tline := 0\n\tfor b.Len() < size {\n\t\tfmt.Fprintf(&b, \"func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\\n\", nonce, i, line, line+3, line*7)\n\t\tline++\n\t}\n\treturn b.String()\n}\n\nfunc seedSession(nonce string) *agent.Session {\n\ts := agent.NewSession(\"You are a terse coding agent reviewing a Go codebase.\")\n\ts.Add(provider.Message{Role: provider.RoleUser, Content: \"Review every file in module \" + nonce + \" one by one. Keep notes short.\"})\n\tfor i := 0; i < fatResults; i++ {\n\t\tid := fmt.Sprintf(\"c%02d\", i)\n\t\tname := fmt.Sprintf(\"src/file%02d.go\", i)\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: \"read_file\", Arguments: fmt.Sprintf(`{\"path\":%q}`, name)}}})\n\t\ts.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: \"read_file\", Content: fakeGoFile(nonce, i, fatBytes)})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf(\"Reviewed %s.\", name)})\n\t}\n\treturn s\n}\n\n// oneShot appends a user message and runs a single completion, returning usage.\nfunc oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {\n\treq := provider.Request{\n\t\tMessages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),\n\t\tMaxTokens: 32,\n\t}\n\tch, err := p.Stream(context.Background(), req)\n\tif err != nil {\n\t\treturn provider.Usage{}, err\n\t}\n\tvar u provider.Usage\n\tfor c := range ch {\n\t\tswitch c.Type {\n\t\tcase provider.ChunkUsage:\n\t\t\tu = *c.Usage\n\t\tcase provider.ChunkError:\n\t\t\treturn u, c.Err\n\t\t}\n\t}\n\treturn u, nil\n}\n\ntype meta struct {\n\tSeededAt time.Time `json:\"seeded_at\"`\n\tNonces map[string]string `json:\"nonces\"`\n\tSeedUse map[string]provider.Usage `json:\"seed_usage\"`\n}\n\nfunc seed(dir string) {\n\tp := prov()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tm := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}\n\tfor _, arm := range []string{\"pruned\", \"control\"} {\n\t\tnonce := fmt.Sprintf(\"%s%d\", arm, time.Now().UnixNano()%1_000_000)\n\t\ts := seedSession(nonce)\n\t\tu, err := oneShot(p, s.Snapshot(), \"How many files have you reviewed so far? Reply with just the number.\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"seed %s: %v\\n\", arm, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.Save(filepath.Join(dir, arm+\".jsonl\")); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tm.Nonces[arm] = nonce\n\t\tm.SeedUse[arm] = u\n\t\tfmt.Printf(\"seeded %-7s prompt=%d hit=%d miss=%d\\n\", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tb, _ := json.MarshalIndent(m, \"\", \" \")\n\tif err := os.WriteFile(filepath.Join(dir, \"meta.json\"), b, 0o644); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc resume(dir string) {\n\tp := prov()\n\traw, err := os.ReadFile(filepath.Join(dir, \"meta.json\"))\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tvar m meta\n\tif err := json.Unmarshal(raw, &m); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tidle := time.Since(m.SeededAt).Round(time.Minute)\n\n\tout := map[string]provider.Usage{}\n\tfor _, arm := range []string{\"pruned\", \"control\"} {\n\t\ts, err := agent.LoadSession(filepath.Join(dir, arm+\".jsonl\"))\n\t\tif err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tprunes := 0\n\t\tif arm == \"pruned\" {\n\t\t\ta := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, \"archive\")}, event.Discard)\n\t\t\tst, err := a.PruneStaleToolResults()\n\t\t\tif err != nil {\n\t\t\t\tfmt.Fprintln(os.Stderr, \"prune:\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tprunes = st.Results\n\t\t}\n\t\tu, err := oneShot(p, s.Snapshot(), \"Which file did you review first? Reply with just the path.\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"resume %s: %v\\n\", arm, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tout[arm] = u\n\t\tfmt.Printf(\"resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\\n\", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tb, _ := json.MarshalIndent(map[string]any{\"idle\": idle.String(), \"resume_usage\": out}, \"\", \" \")\n\tif err := os.WriteFile(filepath.Join(dir, fmt.Sprintf(\"resume-%d.json\", time.Now().Unix())), b, 0o644); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\n\tc, pr := out[\"control\"], out[\"pruned\"]\n\tif c.CacheMissTokens > 0 {\n\t\tfmt.Printf(\"\\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\\n\",\n\t\t\tc.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))\n\t}\n}\n\n// comprehension checks that the agent re-reads a file behind a prune placeholder\n// instead of hallucinating: the answer is a number that exists only in the file.\nfunc comprehension(trials int) {\n\tp := prov()\n\tpass := 0\n\tfor t := 0; t < trials; t++ {\n\t\tdir, err := os.MkdirTemp(\"\", \"cm-e2e-\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tsecret := fmt.Sprintf(\"%d\", 1000+time.Now().UnixNano()%9000)\n\t\tcontent := \"package cfg\\n\\n// retention floor, milliseconds\\nconst cacheRetentionFloor = \" + secret + \"\\n\" + strings.Repeat(\"// padding line filler for prune eligibility\\n\", 400)\n\t\tif err := os.WriteFile(filepath.Join(dir, \"config.go\"), []byte(content), 0o644); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\ts := agent.NewSession(\"You are a terse coding agent. Use tools when you need file contents.\")\n\t\ts.Add(provider.Message{Role: provider.RoleUser, Content: \"Read config.go and note its constants.\"})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: \"r1\", Name: \"read_file\", Arguments: `{\"path\":\"config.go\"}`}}})\n\t\ts.Add(provider.Message{Role: provider.RoleTool, ToolCallID: \"r1\", Name: \"read_file\", Content: content})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Noted the constants in config.go.\"})\n\t\tfor i := 0; i < 4; i++ {\n\t\t\ts.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(\"ack %d\", i)})\n\t\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\t\t}\n\n\t\treg := tool.NewRegistry()\n\t\tfor _, tl := range (builtin.Workspace{Dir: dir}).Tools(\"read_file\") {\n\t\t\treg.Add(tl)\n\t\t}\n\t\ta := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)\n\t\tst, err := a.PruneStaleToolResults()\n\t\tif err != nil || st.Results == 0 {\n\t\t\tfmt.Fprintf(os.Stderr, \"trial %d: prune did not fire (st=%+v err=%v)\\n\", t, st, err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\terr = a.Run(context.Background(), \"What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.\")\n\t\treRead, answered := false, false\n\t\tfor _, msg := range s.Snapshot() {\n\t\t\tif msg.Role == provider.RoleTool && strings.Contains(msg.Content, \"cacheRetentionFloor = \"+secret) {\n\t\t\t\treRead = true\n\t\t\t}\n\t\t\tif msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {\n\t\t\t\tanswered = true\n\t\t\t}\n\t\t}\n\t\tok := err == nil && reRead && answered\n\t\tif ok {\n\t\t\tpass++\n\t\t}\n\t\tfmt.Printf(\"trial %d: re-read=%v answered=%v err=%v\\n\", t, reRead, answered, err)\n\t\tos.RemoveAll(dir)\n\t}\n\tfmt.Printf(\"\\ncomprehension: %d/%d passed\\n\", pass, trials)\n\tif pass < trials {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc main() {\n\tdir := flag.String(\"dir\", \"benchmarks/context-maintenance-e2e/run\", \"state directory for seed/resume\")\n\ttrials := flag.Int(\"trials\", 5, \"comprehension trials\")\n\tflag.Parse()\n\tswitch flag.Arg(0) {\n\tcase \"seed\":\n\t\tseed(*dir)\n\tcase \"resume\":\n\t\tresume(*dir)\n\tcase \"comprehension\":\n\t\tcomprehension(*trials)\n\tdefault:\n\t\tfmt.Fprintln(os.Stderr, \"usage: context-maintenance-e2e [seed|resume|comprehension]\")\n\t\tos.Exit(1)\n\t}\n}\n"}}, "desktop/app_test.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785745635.5242164, "n": 158482, "h": "6987db5e0e2d54d7", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc setDesktopTestCredential(t *testing.T, key, value string) {\n\tt.Helper()\n\tif _, err := config.SetCredential(key, value); err != nil {\n\t\tt.Fatalf(\"SetCredential(%s): %v\", key, err)\n\t}\n}\n\nfunc TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(onboardingKeyEnv, \"inherited-key\")\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a key saved in Reasonix global .env\")\n\t}\n\tsetDesktopTestCredential(t, onboardingKeyEnv, \"saved-key\")\n\tif app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should be false after saving the global credential\")\n\t}\n}\n\nfunc TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+\"=\\n\"), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a non-empty saved credential\")\n\t}\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.Background())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\tif p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || !strings.Contains(p.KeySource, \"Reasonix credentials\") {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tt.Setenv(\"STRIPE_TOKEN\", \"stripe-test-token\")\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\ttools, err := app.AddMCPServer(MCPServerInput{\n\t\tName: \"stripe\",\n\t\tTransport: \"http\",\n\t\tURL: srv.URL,\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer ${STRIPE_TOKEN}\",\n\t\t\t\"X-Org\": \"team\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddMCPServer(stripe): %v\", err)\n\t}\n\tif tools != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1\", tools)\n\t}\n\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp, ok := findPluginEntry(cfg.Plugins, \"stripe\")\n\tif !ok {\n\t\tt.Fatalf(\"stripe plugin missing from config: %+v\", cfg.Plugins)\n\t}\n\tif p.Type != \"http\" || p.URL != srv.URL {\n\t\tt.Fatalf(\"stripe plugin transport = %q url = %q\", p.Type, p.URL)\n\t}\n\tif p.Headers[\"Authorization\"] != \"Bearer ${STRIPE_TOKEN}\" || p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"stripe headers = %+v\", p.Headers)\n\t}\n\n\tview := app.MCPServers()\n\tfor _, s := range view {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif !reflect.DeepEqual(s.HeaderKeys, []string{\"Authorization\", \"X-Org\"}) {\n\t\t\t\tt.Fatalf(\"stripe header keys = %+v\", s.HeaderKeys)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from view: %+v\", view)\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) control.SessionAPI {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "8a7da9a5803ae7c720ae2e135256e45e6bc168ce": {"t": 1785750395.8255236, "n": 158482, "h": "6987db5e0e2d54d7", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc setDesktopTestCredential(t *testing.T, key, value string) {\n\tt.Helper()\n\tif _, err := config.SetCredential(key, value); err != nil {\n\t\tt.Fatalf(\"SetCredential(%s): %v\", key, err)\n\t}\n}\n\nfunc TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(onboardingKeyEnv, \"inherited-key\")\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a key saved in Reasonix global .env\")\n\t}\n\tsetDesktopTestCredential(t, onboardingKeyEnv, \"saved-key\")\n\tif app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should be false after saving the global credential\")\n\t}\n}\n\nfunc TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+\"=\\n\"), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a non-empty saved credential\")\n\t}\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.Background())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\tif p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || !strings.Contains(p.KeySource, \"Reasonix credentials\") {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tt.Setenv(\"STRIPE_TOKEN\", \"stripe-test-token\")\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\ttools, err := app.AddMCPServer(MCPServerInput{\n\t\tName: \"stripe\",\n\t\tTransport: \"http\",\n\t\tURL: srv.URL,\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer ${STRIPE_TOKEN}\",\n\t\t\t\"X-Org\": \"team\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddMCPServer(stripe): %v\", err)\n\t}\n\tif tools != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1\", tools)\n\t}\n\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp, ok := findPluginEntry(cfg.Plugins, \"stripe\")\n\tif !ok {\n\t\tt.Fatalf(\"stripe plugin missing from config: %+v\", cfg.Plugins)\n\t}\n\tif p.Type != \"http\" || p.URL != srv.URL {\n\t\tt.Fatalf(\"stripe plugin transport = %q url = %q\", p.Type, p.URL)\n\t}\n\tif p.Headers[\"Authorization\"] != \"Bearer ${STRIPE_TOKEN}\" || p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"stripe headers = %+v\", p.Headers)\n\t}\n\n\tview := app.MCPServers()\n\tfor _, s := range view {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif !reflect.DeepEqual(s.HeaderKeys, []string{\"Authorization\", \"X-Org\"}) {\n\t\t\t\tt.Fatalf(\"stripe header keys = %+v\", s.HeaderKeys)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from view: %+v\", view)\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) control.SessionAPI {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "4fe39d3092146290b1c60b2a77f869909f77f74e": {"t": 1785750397.864538, "n": 158476, "h": "8353ceeb21b21d6d", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc setDesktopTestCredential(t *testing.T, key, value string) {\n\tt.Helper()\n\tif _, err := config.SetCredential(key, value); err != nil {\n\t\tt.Fatalf(\"SetCredential(%s): %v\", key, err)\n\t}\n}\n\nfunc TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(onboardingKeyEnv, \"inherited-key\")\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a key saved in Reasonix global .env\")\n\t}\n\tsetDesktopTestCredential(t, onboardingKeyEnv, \"saved-key\")\n\tif app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should be false after saving the global credential\")\n\t}\n}\n\nfunc TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+\"=\\n\"), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a non-empty saved credential\")\n\t}\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\tif p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || !strings.Contains(p.KeySource, \"Reasonix credentials\") {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tt.Setenv(\"STRIPE_TOKEN\", \"stripe-test-token\")\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\ttools, err := app.AddMCPServer(MCPServerInput{\n\t\tName: \"stripe\",\n\t\tTransport: \"http\",\n\t\tURL: srv.URL,\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer ${STRIPE_TOKEN}\",\n\t\t\t\"X-Org\": \"team\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddMCPServer(stripe): %v\", err)\n\t}\n\tif tools != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1\", tools)\n\t}\n\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp, ok := findPluginEntry(cfg.Plugins, \"stripe\")\n\tif !ok {\n\t\tt.Fatalf(\"stripe plugin missing from config: %+v\", cfg.Plugins)\n\t}\n\tif p.Type != \"http\" || p.URL != srv.URL {\n\t\tt.Fatalf(\"stripe plugin transport = %q url = %q\", p.Type, p.URL)\n\t}\n\tif p.Headers[\"Authorization\"] != \"Bearer ${STRIPE_TOKEN}\" || p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"stripe headers = %+v\", p.Headers)\n\t}\n\n\tview := app.MCPServers()\n\tfor _, s := range view {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif !reflect.DeepEqual(s.HeaderKeys, []string{\"Authorization\", \"X-Org\"}) {\n\t\t\t\tt.Fatalf(\"stripe header keys = %+v\", s.HeaderKeys)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from view: %+v\", view)\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) control.SessionAPI {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "1d564bd4d5062f059a4432c31b5fd75a66550df7": {"t": 1785750399.4075792, "n": 157561, "h": "4841bc8592e02c12", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc setDesktopTestCredential(t *testing.T, key, value string) {\n\tt.Helper()\n\tif _, err := config.SetCredential(key, value); err != nil {\n\t\tt.Fatalf(\"SetCredential(%s): %v\", key, err)\n\t}\n}\n\nfunc TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(onboardingKeyEnv, \"inherited-key\")\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a key saved in Reasonix global .env\")\n\t}\n\tsetDesktopTestCredential(t, onboardingKeyEnv, \"saved-key\")\n\tif app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should be false after saving the global credential\")\n\t}\n}\n\nfunc TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+\"=\\n\"), 0o600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a non-empty saved credential\")\n\t}\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\tif p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || !strings.Contains(p.KeySource, \"Reasonix credentials\") {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tt.Setenv(\"STRIPE_TOKEN\", \"stripe-test-token\")\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\ttools, err := app.AddMCPServer(MCPServerInput{\n\t\tName: \"stripe\",\n\t\tTransport: \"http\",\n\t\tURL: srv.URL,\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer ${STRIPE_TOKEN}\",\n\t\t\t\"X-Org\": \"team\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddMCPServer(stripe): %v\", err)\n\t}\n\tif tools != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1\", tools)\n\t}\n\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp, ok := findPluginEntry(cfg.Plugins, \"stripe\")\n\tif !ok {\n\t\tt.Fatalf(\"stripe plugin missing from config: %+v\", cfg.Plugins)\n\t}\n\tif p.Type != \"http\" || p.URL != srv.URL {\n\t\tt.Fatalf(\"stripe plugin transport = %q url = %q\", p.Type, p.URL)\n\t}\n\tif p.Headers[\"Authorization\"] != \"Bearer ${STRIPE_TOKEN}\" || p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"stripe headers = %+v\", p.Headers)\n\t}\n\n\tview := app.MCPServers()\n\tfor _, s := range view {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif !reflect.DeepEqual(s.HeaderKeys, []string{\"Authorization\", \"X-Org\"}) {\n\t\t\t\tt.Fatalf(\"stripe header keys = %+v\", s.HeaderKeys)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from view: %+v\", view)\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "2dd6fc6ddf40c3a04544e2a706404755c6dc0757": {"t": 1785750401.3473246, "n": 157108, "h": "e0b32f787dec21e6", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc setDesktopTestCredential(t *testing.T, key, value string) {\n\tt.Helper()\n\tif _, err := config.SetCredential(key, value); err != nil {\n\t\tt.Fatalf(\"SetCredential(%s): %v\", key, err)\n\t}\n}\n\nfunc TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(onboardingKeyEnv, \"inherited-key\")\n\n\tapp := NewApp()\n\tif !app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should require a key saved in Reasonix global .env\")\n\t}\n\tsetDesktopTestCredential(t, onboardingKeyEnv, \"saved-key\")\n\tif app.NeedsOnboarding() {\n\t\tt.Fatal(\"NeedsOnboarding should be false after saving the global credential\")\n\t}\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\tif p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || !strings.Contains(p.KeySource, \"Reasonix credentials\") {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tt.Setenv(\"STRIPE_TOKEN\", \"stripe-test-token\")\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\ttools, err := app.AddMCPServer(MCPServerInput{\n\t\tName: \"stripe\",\n\t\tTransport: \"http\",\n\t\tURL: srv.URL,\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer ${STRIPE_TOKEN}\",\n\t\t\t\"X-Org\": \"team\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddMCPServer(stripe): %v\", err)\n\t}\n\tif tools != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1\", tools)\n\t}\n\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp, ok := findPluginEntry(cfg.Plugins, \"stripe\")\n\tif !ok {\n\t\tt.Fatalf(\"stripe plugin missing from config: %+v\", cfg.Plugins)\n\t}\n\tif p.Type != \"http\" || p.URL != srv.URL {\n\t\tt.Fatalf(\"stripe plugin transport = %q url = %q\", p.Type, p.URL)\n\t}\n\tif p.Headers[\"Authorization\"] != \"Bearer ${STRIPE_TOKEN}\" || p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"stripe headers = %+v\", p.Headers)\n\t}\n\n\tview := app.MCPServers()\n\tfor _, s := range view {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif !reflect.DeepEqual(s.HeaderKeys, []string{\"Authorization\", \"X-Org\"}) {\n\t\t\t\tt.Fatalf(\"stripe header keys = %+v\", s.HeaderKeys)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from view: %+v\", view)\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "7e7c7eda097114c87b2247e036c0df8311bd7c62": {"t": 1785750403.2691956, "n": 156672, "h": "997981e3167afb7e", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc setDesktopTestCredential(t *testing.T, key, value string) {\n\tt.Helper()\n\tif _, err := config.SetCredential(key, value); err != nil {\n\t\tt.Fatalf(\"SetCredential(%s): %v\", key, err)\n\t}\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\tif p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = true, want false because workspace .env is ignored: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || !strings.Contains(p.KeySource, \"Reasonix credentials\") {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\tsetDesktopTestCredential(t, \"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tsetDesktopTestCredential(t, \"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tt.Setenv(\"STRIPE_TOKEN\", \"stripe-test-token\")\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\ttools, err := app.AddMCPServer(MCPServerInput{\n\t\tName: \"stripe\",\n\t\tTransport: \"http\",\n\t\tURL: srv.URL,\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer ${STRIPE_TOKEN}\",\n\t\t\t\"X-Org\": \"team\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddMCPServer(stripe): %v\", err)\n\t}\n\tif tools != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1\", tools)\n\t}\n\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp, ok := findPluginEntry(cfg.Plugins, \"stripe\")\n\tif !ok {\n\t\tt.Fatalf(\"stripe plugin missing from config: %+v\", cfg.Plugins)\n\t}\n\tif p.Type != \"http\" || p.URL != srv.URL {\n\t\tt.Fatalf(\"stripe plugin transport = %q url = %q\", p.Type, p.URL)\n\t}\n\tif p.Headers[\"Authorization\"] != \"Bearer ${STRIPE_TOKEN}\" || p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"stripe headers = %+v\", p.Headers)\n\t}\n\n\tview := app.MCPServers()\n\tfor _, s := range view {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif !reflect.DeepEqual(s.HeaderKeys, []string{\"Authorization\", \"X-Org\"}) {\n\t\t\t\tt.Fatalf(\"stripe header keys = %+v\", s.HeaderKeys)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from view: %+v\", view)\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "a2f335336abaa6d7499e2a35ba28d424d4b2ad6b": {"t": 1785750404.8377254, "n": 157050, "h": "f8ea5086fb857961", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tt.Setenv(\"STRIPE_TOKEN\", \"stripe-test-token\")\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\ttools, err := app.AddMCPServer(MCPServerInput{\n\t\tName: \"stripe\",\n\t\tTransport: \"http\",\n\t\tURL: srv.URL,\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer ${STRIPE_TOKEN}\",\n\t\t\t\"X-Org\": \"team\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddMCPServer(stripe): %v\", err)\n\t}\n\tif tools != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1\", tools)\n\t}\n\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp, ok := findPluginEntry(cfg.Plugins, \"stripe\")\n\tif !ok {\n\t\tt.Fatalf(\"stripe plugin missing from config: %+v\", cfg.Plugins)\n\t}\n\tif p.Type != \"http\" || p.URL != srv.URL {\n\t\tt.Fatalf(\"stripe plugin transport = %q url = %q\", p.Type, p.URL)\n\t}\n\tif p.Headers[\"Authorization\"] != \"Bearer ${STRIPE_TOKEN}\" || p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"stripe headers = %+v\", p.Headers)\n\t}\n\n\tview := app.MCPServers()\n\tfor _, s := range view {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif !reflect.DeepEqual(s.HeaderKeys, []string{\"Authorization\", \"X-Org\"}) {\n\t\t\t\tt.Fatalf(\"stripe header keys = %+v\", s.HeaderKeys)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from view: %+v\", view)\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) control.SessionAPI {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "e19fbdcc00749b2a180f8597042a72fd94f33176": {"t": 1785750406.4207575, "n": 156027, "h": "dad356a3a3d7a1c5", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tt.Setenv(\"STRIPE_TOKEN\", \"stripe-test-token\")\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\ttools, err := app.AddMCPServer(MCPServerInput{\n\t\tName: \"stripe\",\n\t\tTransport: \"http\",\n\t\tURL: srv.URL,\n\t\tHeaders: map[string]string{\n\t\t\t\"Authorization\": \"Bearer ${STRIPE_TOKEN}\",\n\t\t\t\"X-Org\": \"team\",\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddMCPServer(stripe): %v\", err)\n\t}\n\tif tools != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1\", tools)\n\t}\n\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp, ok := findPluginEntry(cfg.Plugins, \"stripe\")\n\tif !ok {\n\t\tt.Fatalf(\"stripe plugin missing from config: %+v\", cfg.Plugins)\n\t}\n\tif p.Type != \"http\" || p.URL != srv.URL {\n\t\tt.Fatalf(\"stripe plugin transport = %q url = %q\", p.Type, p.URL)\n\t}\n\tif p.Headers[\"Authorization\"] != \"Bearer ${STRIPE_TOKEN}\" || p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"stripe headers = %+v\", p.Headers)\n\t}\n\n\tview := app.MCPServers()\n\tfor _, s := range view {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif !reflect.DeepEqual(s.HeaderKeys, []string{\"Authorization\", \"X-Org\"}) {\n\t\t\t\tt.Fatalf(\"stripe header keys = %+v\", s.HeaderKeys)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from view: %+v\", view)\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "fe3bf9ccfa629dbf7623d4a8879e29fd68496e81": {"t": 1785750410.0494015, "n": 155605, "h": "5c9e6007d7a8aa64", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) control.SessionAPI {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "7ba4295565251b6911e7761bd0e825d29aa75cec": {"t": 1785750412.1067183, "n": 154582, "h": "72718ab99f4ee911", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"default MCP view = %+v, want deferred automatic idle\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\tctrl, err := boot.Build(ctx, boot.Options{\n\t\tWorkspaceRoot: dir,\n\t\tSessionDir: filepath.Join(dir, \"sessions\"),\n\t\tSharedHost: sharedHost,\n\t\tStderr: io.Discard,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"boot.Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(3 * time.Second)\n\tfor !sharedHost.HasClient(\"h\") && time.Now().Before(deadline) {\n\t\ttime.Sleep(25 * time.Millisecond)\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatalf(\"background MCP did not auto-connect; connecting=%v failures=%+v\", sharedHost.ConnectingServers(), sharedHost.Failures())\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"test\": {\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: ctrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"test\"\n\n\tview := app.MCPServers()\n\tif len(view) != 1 || view[0].Name != \"h\" || view[0].Status != \"connected\" || view[0].StartIntent != \"automatic\" || view[0].RuntimeState != \"ready\" || view[0].Tools != 1 {\n\t\tt.Fatalf(\"MCPServers() = %+v, want h connected automatic ready with one tool\", view)\n\t}\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundIdle := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundIdle = true\n\t\t\tif s.Status != \"deferred\" || s.StartIntent != \"automatic\" || s.RuntimeState != \"idle\" {\n\t\t\t\tt.Fatalf(\"initial codegraph server = %+v, want automatic idle background state\", s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundIdle {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "2ff0c3de3f6ca80b583929a8f6d822cff042896f": {"t": 1785750413.6707566, "n": 153719, "h": "6305589484385b5b", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) control.SessionAPI {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "d9e453a1bedd675c226660cab033da35421b141d": {"t": 1785750415.3834207, "n": 152696, "h": "d1dfbceeacda8628", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || official[\"mimo-api\"] || official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want only deepseek\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-pro\"].Added || !providers[\"mimo-pro\"].KeySet || providers[\"mimo-pro\"].BuiltIn {\n\t\tt.Fatalf(\"mimo-pro provider = %+v, want inferred custom key-set provider\", providers[\"mimo-pro\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif _, ok := providers[\"mimo-token-plan\"]; ok {\n\t\tt.Fatalf(\"mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-pro\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-pro\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-pro/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t\t\"mimo-pro/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-pro/mimo-v2.5-pro\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"other\", Kind: \"openai\", BaseURL: \"https://example.invalid/v1\", Model: \"other-model\"})\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"other/other-model\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "c7344b3ac547cae6766e0ea5cfaa4d882cc1577a": {"t": 1785750416.9425673, "n": 153725, "h": "33d28e0c6932fe7d", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) control.SessionAPI {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "9fb4088d4c2b3b75ecc184dc774ba7b38f08b816": {"t": 1785750418.4873455, "n": 152074, "h": "5fe10a7549c03a50", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) control.SessionAPI {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "fea81e3aa5194419a5cb14f137642778613d094b": {"t": 1785750420.0494916, "n": 152702, "h": "cdab43615b240dd5", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tapp.mu.Lock()\n\tapp.tabs[\"survivor\"] = &WorkspaceTab{ID: \"survivor\", Scope: \"global\", Ready: true, disabledMCP: map[string]ServerView{}}\n\tapp.tabOrder = []string{\"test\", \"survivor\"}\n\tapp.activeTabID = \"test\"\n\tapp.mu.Unlock()\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external close follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\n\tif err := app.CloseTab(\"test\"); err != nil {\n\t\tt.Fatalf(\"CloseTab: %v\", err)\n\t}\n\tafterClose, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after close: %v\", err)\n\t}\n\tif !strings.Contains(string(afterClose), \"external close follow-up\") {\n\t\tt.Fatalf(\"closing read-only channel tab overwrote external append:\\n%s\", afterClose)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "dbb6f83acde4f4c5f0d77a3931acd39b8234f38c": {"t": 1785750422.1663532, "n": 151051, "h": "786e114ee7be4d48", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl control.SessionAPI) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "171cc38f9e8a80312d4eb1c8240bbc5e3d78de0d": {"t": 1785750423.7092865, "n": 152077, "h": "9c716c3441c79041", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "3b86bf355ad833a51086a7e0210c746c4323b595": {"t": 1785750425.2609317, "n": 152023, "h": "7dcbf6873dd02a06", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "0b3c12013cd8079c6a5062aa161e748cf62f8aaa": {"t": 1785750426.8075368, "n": 151053, "h": "28667de5a6f4d82d", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "58b815f3bc698dd60090bd0f08ce3901fc449e16": {"t": 1785750428.3431652, "n": 148469, "h": "b920d6228d6d1d20", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\nfunc desktopMCPHTTPServer(t *testing.T) *httptest.Server {\n\tt.Helper()\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\t\thttp.Error(w, \"bad body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tw.WriteHeader(http.StatusAccepted)\n\t\t\treturn\n\t\t}\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"h\", \"version\": \"0\"},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"greet\",\n\t\t\t\t\"description\": \"Greet someone.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\tdefault:\n\t\t\tresult = map[string]any{}\n\t\t}\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(resp)\n\t}))\n}\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tsrv := desktopMCPHTTPServer(t)\n\tdefer srv.Close()\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(fmt.Sprintf(`\n[[plugins]]\nname = \"h\"\ntype = \"http\"\nurl = %q\n`, srv.URL)), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\tdefer cancel()\n\tsharedHost := plugin.NewHost()\n\tdefer sharedHost.Close()\n\ttools, err := sharedHost.Add(ctx, plugin.Spec{Name: \"h\", Type: \"http\", URL: srv.URL})\n\tif err != nil {\n\t\tt.Fatalf(\"sharedHost.Add: %v\", err)\n\t}\n\n\tactiveRegistry := tool.NewRegistry()\n\tsiblingRegistry := tool.NewRegistry()\n\tfor _, mt := range tools {\n\t\tactiveRegistry.Add(mt)\n\t\tsiblingRegistry.Add(mt)\n\t}\n\tactiveCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})\n\tsiblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\n\t\t\"active\": {\n\t\t\tID: \"active\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: activeCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t\t\"sibling\": {\n\t\t\tID: \"sibling\",\n\t\t\tScope: \"global\",\n\t\t\tWorkspaceRoot: dir,\n\t\t\tReady: true,\n\t\t\tCtrl: siblingCtrl,\n\t\t\tSharedHostKey: dir,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t},\n\t}\n\tapp.activeTabID = \"active\"\n\n\tif err := app.SetMCPServerEnabled(\"h\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,false): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); found {\n\t\tt.Fatal(\"active tab still has h tools after disabling the shared server\")\n\t}\n\tif _, found := siblingRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"sibling tab lost h tools when active tab disabled the shared server\")\n\t}\n\tif !sharedHost.HasClient(\"h\") {\n\t\tt.Fatal(\"shared host client was removed by a per-tab disable\")\n\t}\n\tview := app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"disabled\" {\n\t\tt.Fatalf(\"Capabilities after disable = %+v, want h disabled for the active tab\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"h\", true); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(h,true): %v\", err)\n\t}\n\tif _, found := activeRegistry.Get(\"mcp__h__greet\"); !found {\n\t\tt.Fatal(\"active tab did not re-register h tools from the existing shared client\")\n\t}\n\tview = app.Capabilities()\n\tif len(view.Servers) != 1 || view.Servers[0].Name != \"h\" || view.Servers[0].Status != \"connected\" {\n\t\tt.Fatalf(\"Capabilities after re-enable = %+v, want h connected for the active tab\", view.Servers)\n\t}\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "8d42c109b1a848c87ae2ec47fca9e3084feb5336": {"t": 1785750429.8807733, "n": 145451, "h": "eada29ddbde9a33d", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "d97eafc6eae52b28d0a3256ebd83df953f589cb5": {"t": 1785750431.5641317, "n": 144481, "h": "4787422a8de384f2", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"icon\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"workspace\", \"git_branch\", \"model\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCheckUpdates(false); err != nil {\n\t\tt.Fatalf(\"set desktop check updates: %v\", err)\n\t}\n\tuserCfg.Bot.Enabled = true\n\tuserCfg.Bot.Allowlist.Enabled = true\n\tuserCfg.Bot.Allowlist.QQUsers = []string{\"alice\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tgot := NewApp().DesktopStartupSettings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.DisplayMode != \"standard\" || got.StatusBarStyle != \"icon\" || got.CheckUpdates {\n\t\tt.Fatalf(\"DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs\", got)\n\t}\n\tif want := []string{\"workspace\", \"git_branch\", \"model\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"DesktopStartupSettings status bar items = %v, want %v\", got.StatusBarItems, want)\n\t}\n\tif !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{\"alice\"}) {\n\t\tt.Fatalf(\"DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot\", got.Bot)\n\t}\n\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal DesktopStartupSettings: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"providers\") || strings.Contains(string(raw), \"officialProviders\") || strings.Contains(string(raw), \"providerKinds\") {\n\t\tt.Fatalf(\"DesktopStartupSettings must not include full Settings provider payload: %s\", raw)\n\t}\n}\n\nfunc BenchmarkDesktopSettingsPayloads(b *testing.B) {\n\thome := b.TempDir()\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n\tb.Setenv(\"HOME\", home)\n\tb.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tb.Setenv(\"USERPROFILE\", home)\n\tb.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tb.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tb.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tb.Setenv(\"AppData\", appData)\n\tb.Setenv(\"SHARED_PROVIDER_KEY\", \"sk-test\")\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tfor i := 0; i < 40; i++ {\n\t\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{\n\t\t\tName: fmt.Sprintf(\"custom-%02d\", i),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://example.invalid/v1\",\n\t\t\tAPIKeyEnv: \"SHARED_PROVIDER_KEY\",\n\t\t\tModels: []string{\"model-a\", \"model-b\"},\n\t\t\tDefault: \"model-a\",\n\t\t})\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tb.Fatalf(\"save config: %v\", err)\n\t}\n\tapp := NewApp()\n\n\tb.Run(\"Settings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.Settings()\n\t\t}\n\t})\n\tb.Run(\"DesktopStartupSettings\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t_ = app.DesktopStartupSettings()\n\t\t}\n\t})\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "01be75333f18b69e9bd9f899480df9ff7e961a6d": {"t": 1785750433.1001325, "n": 140855, "h": "bfa6aef84b130e14", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.AddOfficialProviderAccess(\"deepseek\", \"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"AddOfficialProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"provider key should not be saved after rejected add access:\\n%s\", data)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-b/model-b1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-unaffected-job\"), \"prov-b/model-b1\")\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"DeleteProvider with unaffected background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"unaffected provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"mimo-pro/mimo-v2.5-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"provider-access-unaffected-job\"), \"mimo-token-plan/mimo-v2.5-pro\")\n\n\terr := app.RemoveProviderAccess(\"deepseek\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"RemoveProviderAccess with background job error = %v, want active-work guard\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek\"] && !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access should still contain deepseek after rejected removal: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"connect-key-job\"), \"deepseek-flash/deepseek-v4-flash\")\n\n\t_, err := app.ConnectKey(\"sk-test\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"ConnectKey with background job error = %v, want active-work guard\", err)\n\t}\n\tif data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), \"DEEPSEEK_API_KEY\") {\n\t\tt.Fatalf(\"onboarding key should not be saved after rejected connect:\\n%s\", data)\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-enabled-job\"), \"\")\n\n\terr := app.SetMCPServerEnabled(\"time\", false)\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerEnabled with background job error = %v, want active-work guard\", err)\n\t}\n\tif tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {\n\t\tt.Fatalf(\"disabled MCP state changed after rejected toggle: %+v\", tab)\n\t}\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(newBackgroundJobController(t, \"mcp-tier-job\"), \"\")\n\n\terr := app.SetMCPServerTier(\"broken\", \"background\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetMCPServerTier with background job error = %v, want active-work guard\", err)\n\t}\n\tdata, readErr := os.ReadFile(config.UserConfigPath())\n\tif readErr != nil {\n\t\tt.Fatalf(\"read config: %v\", readErr)\n\t}\n\tif !strings.Contains(string(data), `tier = \"lazy\"`) {\n\t\tt.Fatalf(\"plugin config changed after rejected tier update:\\n%s\", data)\n\t}\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc newBackgroundJobController(t *testing.T, label string) *control.Controller {\n\tt.Helper()\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, label+\".jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tt.Cleanup(ctrl.Close)\n\tjm.StartForSession(agent.BranchID(path), \"bash\", label, func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\treturn ctrl\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "07e688bbaa7249ca64418a4189ba26a492db6059": {"t": 1785750434.7481313, "n": 129873, "h": "535b6613777bcc79", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] },\n \"keep\": { \"command\": \"keep-mcp\" }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.RemoveMCPServer(\"codegraph\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\"codegraph still merged after remove: %+v\", cfg.Plugins)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"keep\"); !ok {\n\t\tt.Fatalf(\"unrelated .mcp.json server should be preserved: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \".mcp.json\"), []byte(`{\n \"mcpServers\": {\n \"codegraph\": { \"command\": \"codegraph\", \"args\": [\"serve\", \"--mcp\"] }\n }\n}`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"codegraph\", MCPServerInput{\n\t\tName: \"codegraph\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tArgs: []string{\"serve\", \"--mcp\"},\n\t\tEnv: map[string]string{\"CODEGRAPH_LOG\": \"debug\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(.mcp.json codegraph): %v\", err)\n\t}\n\n\traw, err := os.ReadFile(filepath.Join(dir, \".mcp.json\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvar doc struct {\n\t\tMCPServers map[string]struct {\n\t\t\tCommand string `json:\"command\"`\n\t\t\tArgs []string `json:\"args\"`\n\t\t\tEnv map[string]string `json:\"env\"`\n\t\t} `json:\"mcpServers\"`\n\t}\n\tif err := json.Unmarshal(raw, &doc); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tgot := doc.MCPServers[\"codegraph\"]\n\tif got.Command != \"reasonix-missing-mcp-binary\" || !reflect.DeepEqual(got.Args, []string{\"serve\", \"--mcp\"}) || got.Env[\"CODEGRAPH_LOG\"] != \"debug\" {\n\t\tt.Fatalf(\".mcp.json codegraph = %+v, want updated command/args/env\", got)\n\t}\n\tif _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, \"codegraph\"); ok {\n\t\tt.Fatalf(\".mcp.json update should not create a user config shadow entry\")\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "af84b038117044b985c2f53e0afef6f6ba568a54": {"t": 1785750436.2829428, "n": 134519, "h": "58fca21ec986e3a5", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 500 * time.Millisecond\n\tslack := 300 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+slack {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "ade40e08774d548b8e9d523219736a03331ba006": {"t": 1785750437.8286562, "n": 134483, "h": "9897c3248345693d", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 150 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+100*time.Millisecond {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 150 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+100*time.Millisecond {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "94aefd32aba69d119849d36a4322c2e235bc4613": {"t": 1785750439.5130413, "n": 133144, "h": "7dda3afc9d2035bf", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 150 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+100*time.Millisecond {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 150 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+100*time.Millisecond {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsCleanupPending(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tpendingPath := filepath.Join(dir, \"pending.jsonl\")\n\tfor _, path := range []string{activePath, pendingPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\tif err := agent.MarkCleanupPending(pendingPath, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"ResumeSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {\n\t\tt.Fatalf(\"active session path after rejected resume = %q, want %q\", got, activePath)\n\t}\n\tif _, err := app.OpenChannelSessionForTab(\"test\", pendingPath); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {\n\t\tt.Fatalf(\"rejected channel open should not make tab read-only: %+v\", meta)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "998750f133b08d6146dbfc2143123aa2be72fd78": {"t": 1785750441.0600493, "n": 131607, "h": "0a760f1b9945e8ab", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"stuck-delete.jsonl\")\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tfor _, p := range []string{path, keepPath} {\n\t\tif err := os.WriteFile(p, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", p, err)\n\t\t}\n\t}\n\n\tgrace := 150 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, path)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t\tkeepCtrl.Close()\n\t}()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tstart := time.Now()\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+100*time.Millisecond {\n\t\tt.Fatalf(\"DeleteSession took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(path) {\n\t\tt.Fatalf(\"stuck delete should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"stuck session file should remain until delayed cleanup: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tprojectRoot := t.TempDir()\n\ttopicID := \"topic_stuck_trash\"\n\tif err := addProject(projectRoot, \"\"); err != nil {\n\t\tt.Fatalf(\"add project: %v\", err)\n\t}\n\tif err := setTopicTitle(projectRoot, topicID, \"Stuck trash\"); err != nil {\n\t\tt.Fatalf(\"set topic title: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir sessions: %v\", err)\n\t}\n\tsessionPath := writeTopicSession(t, dir, \"stuck-topic.jsonl\", topicID, \"Stuck trash\", projectRoot)\n\n\tgrace := 150 * time.Millisecond\n\tjm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: \"test\", Jobs: jm, WorkspaceRoot: projectRoot})\n\treleaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)\n\tdefer func() {\n\t\treleaseJob()\n\t\tctrl.Close()\n\t}()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"stuck\": {\n\t\t\t\tID: \"stuck\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: topicID,\n\t\t\t\tTopicTitle: \"Stuck trash\",\n\t\t\t\tCtrl: ctrl,\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t\t\"keep\": {\n\t\t\t\tID: \"keep\",\n\t\t\t\tScope: \"project\",\n\t\t\t\tWorkspaceRoot: projectRoot,\n\t\t\t\tTopicID: \"topic_keep\",\n\t\t\t\tTopicTitle: \"Keep\",\n\t\t\t\tReady: true,\n\t\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t\t},\n\t\t},\n\t\ttabOrder: []string{\"stuck\", \"keep\"},\n\t\tactiveTabID: \"stuck\",\n\t}\n\n\tstart := time.Now()\n\tif err := app.TrashTopic(topicID); err != nil {\n\t\tt.Fatalf(\"TrashTopic(stuck job): %v\", err)\n\t}\n\telapsed := time.Since(start)\n\tif elapsed > grace+100*time.Millisecond {\n\t\tt.Fatalf(\"TrashTopic took %s, want one teardown grace plus scheduling slack\", elapsed)\n\t}\n\tif !agent.IsCleanupPending(sessionPath) {\n\t\tt.Fatalf(\"stuck topic trash should mark cleanup pending\")\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"stuck topic session should remain until delayed trash: %v\", err)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {\n\tt.Helper()\n\tstarted := make(chan struct{})\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(sessionPath), \"bash\", \"stuck job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\t<-release\n\t\treturn \"\", ctx.Err()\n\t})\n\tselect {\n\tcase <-started:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"background job never started\")\n\t}\n\treleased := false\n\treturn func() {\n\t\tif released {\n\t\t\treturn\n\t\t}\n\t\treleased = true\n\t\tclose(release)\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "4c18de03ce6cb858efc8c4729f6e68da2e54d006": {"t": 1785750442.5501032, "n": 128259, "h": "07431796e00f2f01", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tcfg := config.Default()\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://test.example.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\thome := isolateDesktopUserDirs(t)\n\tt.Chdir(home) // avoid project reasonix.toml\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "7e7c47d7e204ca4afdf542537a58582c24715996": {"t": 1785750444.04944, "n": 128628, "h": "9add41c48d0c3c49", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running-job.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tjm := jobs.NewManager(event.Discard)\n\toldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tstarted := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"clear artifact\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tclose(started)\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\t<-started\n\tjobsDir := jobs.ArtifactDir(path)\n\tif _, err := os.Stat(jobsDir); err != nil {\n\t\tt.Fatalf(\"job sidecar should exist before clear: %v\", err)\n\t}\n\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\tif _, err := os.Stat(jobsDir); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old job sidecar should be removed after clear, stat err = %v\", err)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}, "089c7a9a32a50beb187ea49b8e8dd5fd2d7d67a6": {"t": 1785750445.5448003, "n": 127289, "h": "50a2926516593127", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/tool\"\n)\n\n// setTestCtrl creates a minimal workspace tab (if needed) and sets its\n// controller, so tests don't depend on the old App.ctrl field.\nfunc (a *App) setTestCtrl(ctrl *control.Controller, model string) {\n\tif len(a.tabs) == 0 {\n\t\ttab := &WorkspaceTab{\n\t\t\tID: \"test\",\n\t\t\tScope: \"global\",\n\t\t\tReady: true,\n\t\t\tdisabledMCP: map[string]ServerView{},\n\t\t}\n\t\ta.tabs = map[string]*WorkspaceTab{\"test\": tab}\n\t\ta.activeTabID = \"test\"\n\t}\n\ttab := a.tabs[\"test\"]\n\ttab.Ctrl = ctrl\n\ta.bindControllerDisplayRecorder(ctrl)\n\ttab.model = model\n}\n\nfunc isolateDesktopUserDirs(t *testing.T) string {\n\tt.Helper()\n\thome := robustTempDir(t)\n\txdg := filepath.Join(home, \".config\")\n\tappData := filepath.Join(home, \"AppData\")\n\tfor _, dir := range []string{xdg, appData} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", xdg)\n\tt.Setenv(\"REASONIX_STATE_HOME\", filepath.Join(home, \"state\"))\n\tt.Setenv(\"REASONIX_CACHE_HOME\", filepath.Join(home, \"cache\"))\n\tt.Setenv(\"AppData\", appData)\n\treturn home\n}\n\nfunc providerNamesFromView(providers []ProviderView) []string {\n\tout := make([]string, 0, len(providers))\n\tfor _, p := range providers {\n\t\tout = append(out, p.Name)\n\t}\n\treturn out\n}\n\nfunc modelRefsFromView(models []ModelInfo) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, m := range models {\n\t\tout[m.Ref] = true\n\t}\n\treturn out\n}\n\ntype desktopFakeTool struct {\n\tname string\n}\n\nfunc (t desktopFakeTool) Name() string { return t.name }\n\nfunc (desktopFakeTool) Description() string { return \"fake desktop tool\" }\n\nfunc (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{\"type\":\"object\"}`) }\n\nfunc (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return \"\", nil }\n\nfunc (desktopFakeTool) ReadOnly() bool { return true }\n\ntype desktopAskRuntimeRunner struct {\n\task func(context.Context) error\n}\n\nfunc (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {\n\tif r.ask == nil {\n\t\treturn nil\n\t}\n\treturn r.ask(ctx)\n}\n\nfunc TestCommandsIncludesEffortNotThinking(t *testing.T) {\n\tapp := NewApp()\n\tcmds := app.Commands()\n\tif !hasCommand(cmds, \"effort\") {\n\t\tt.Fatalf(\"Commands() should include effort: %+v\", cmds)\n\t}\n\tif hasCommand(cmds, \"thinking\") {\n\t\tt.Fatalf(\"Commands() should not include thinking: %+v\", cmds)\n\t}\n}\n\nfunc TestMetaForTabIncludesWorkspaceContext(t *testing.T) {\n\tif _, err := exec.LookPath(\"git\"); err != nil {\n\t\tt.Skip(\"git not installed\")\n\t}\n\tisolateDesktopUserDirs(t)\n\n\trepo := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tif err := os.Chdir(orig); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}()\n\tif err := os.Chdir(repo); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trunGit(t, \"init\")\n\trunGit(t, \"checkout\", \"-b\", \"feature/meta\")\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: repo,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tgot := app.MetaForTab(\"tab-1\")\n\tif got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {\n\t\tt.Fatalf(\"workspace fields = cwd:%q root:%q path:%q, want %q\", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)\n\t}\n\tif got.WorkspaceName != filepath.Base(repo) {\n\t\tt.Fatalf(\"workspaceName = %q, want %q\", got.WorkspaceName, filepath.Base(repo))\n\t}\n\traw, err := json.Marshal(got)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal meta: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"meta should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n\tif got.GitBranch != \"feature/meta\" {\n\t\tt.Fatalf(\"gitBranch = %q, want feature/meta\", got.GitBranch)\n\t}\n}\n\nfunc TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tconfiguredSandboxRoot := filepath.Join(t.TempDir(), \"sandbox\")\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tcfg.Sandbox.WorkspaceRoot = configuredSandboxRoot\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\tapp.tabOrder = []string{\"tab-1\"}\n\n\traw, err := json.Marshal(app.ListTabs())\n\tif err != nil {\n\t\tt.Fatalf(\"marshal tabs: %v\", err)\n\t}\n\tif strings.Contains(string(raw), \"sandboxPath\") || strings.Contains(string(raw), configuredSandboxRoot) {\n\t\tt.Fatalf(\"tab metadata should not expose configured sandbox root as sandboxPath: %s\", raw)\n\t}\n}\n\nfunc TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {\n\tasks := make(chan event.Ask, 1)\n\tdone := make(chan event.Event, 1)\n\trunner := &desktopAskRuntimeRunner{}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tswitch e.Kind {\n\t\t\tcase event.AskRequest:\n\t\t\t\tasks <- e.Ask\n\t\t\tcase event.TurnDone:\n\t\t\t\tdone <- e\n\t\t\t}\n\t\t}),\n\t})\n\trunner.ask = func(ctx context.Context) error {\n\t\t_, err := ctrl.Ask(ctx, []event.AskQuestion{{\n\t\t\tID: \"choice\",\n\t\t\tPrompt: \"Pick one\",\n\t\t\tOptions: []event.AskOption{{Label: \"A\"}, {Label: \"B\"}},\n\t\t}})\n\t\treturn err\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov/model\")\n\tapp.tabOrder = []string{\"test\"}\n\tctrl.Send(\"ask user\")\n\tselect {\n\tcase <-asks:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for ask request\")\n\t}\n\n\ttabs := app.ListTabs()\n\tif len(tabs) != 1 {\n\t\tt.Fatalf(\"tabs = %d, want 1\", len(tabs))\n\t}\n\tif !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {\n\t\tt.Fatalf(\"tab runtime = running:%v pending:%v cancellable:%v cancel:%v\", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)\n\t}\n\n\tapp.CancelTab(\"test\")\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"timed out waiting for turn_done\")\n\t}\n}\n\nfunc TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tworkspace := t.TempDir()\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"tab-1\": {\n\t\tID: \"tab-1\",\n\t\tScope: \"project\",\n\t\tWorkspaceRoot: workspace,\n\t\tReady: true,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}}\n\tapp.activeTabID = \"tab-1\"\n\n\tif got := app.MetaForTab(\"tab-1\"); got.GitBranch != \"\" {\n\t\tt.Fatalf(\"gitBranch = %q, want empty\", got.GitBranch)\n\t}\n}\n\nfunc TestEffortDefaultsBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Effort()\n\tif !got.Supported || got.Current != \"auto\" || got.Default != \"high\" || !hasLevel(got.Levels, \"auto\") {\n\t\tt.Fatalf(\"pre-startup Effort() = %+v, want auto with DeepSeek default high\", got)\n\t}\n}\n\nfunc TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tview := NewApp().Memory()\n\tif view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {\n\t\tt.Fatalf(\"Memory() arrays must be non-nil before startup: %+v\", view)\n\t}\n\traw, err := json.Marshal(view)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal Memory(): %v\", err)\n\t}\n\tfor _, bad := range []string{`\"docs\":null`, `\"facts\":null`, `\"archives\":null`, `\"scopes\":null`} {\n\t\tif strings.Contains(string(raw), bad) {\n\t\t\tt.Fatalf(\"Memory() JSON contains %s; frontend expects []: %s\", bad, raw)\n\t\t}\n\t}\n}\n\nfunc TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tuserDir := t.TempDir()\n\tcwd := t.TempDir()\n\tstore := memory.Store{Dir: filepath.Join(userDir, \"projects\", \"test\", \"memory\")}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"active-fact\",\n\t\tTitle: \"Active fact\",\n\t\tDescription: \"Still applies\",\n\t\tType: memory.TypeProject,\n\t\tBody: \"Active body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"archived-fact\",\n\t\tDescription: \"No longer applies\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Archived body\",\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := store.Archive(\"archived-fact\"); err != nil {\n\t\tt.Fatalf(\"Archive: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Memory: &memory.Set{\n\t\tDocs: []memory.Source{{Path: filepath.Join(cwd, \"AGENTS.md\"), Scope: memory.ScopeProject, Body: \"Project instructions\"}},\n\t\tStore: store,\n\t\tCWD: cwd,\n\t\tUserDir: userDir,\n\t}}), \"test-model\")\n\n\tview := app.Memory()\n\tif !view.Available || view.StoreDir != store.Dir {\n\t\tt.Fatalf(\"Memory() availability/store = %v/%q, want true/%q\", view.Available, view.StoreDir, store.Dir)\n\t}\n\tif len(view.Docs) != 1 || view.Docs[0].Scope != \"project\" || !strings.Contains(view.Docs[0].Body, \"Project instructions\") {\n\t\tt.Fatalf(\"Memory() docs = %+v\", view.Docs)\n\t}\n\tif len(view.Facts) != 1 || view.Facts[0].Name != \"active-fact\" || view.Facts[0].Type != \"project\" {\n\t\tt.Fatalf(\"Memory() active facts = %+v\", view.Facts)\n\t}\n\tif len(view.Archives) != 1 || view.Archives[0].Name != \"archived-fact\" || view.Archives[0].Type != \"feedback\" ||\n\t\tview.Archives[0].Path == \"\" || view.Archives[0].ArchivedAt == \"\" {\n\t\tt.Fatalf(\"Memory() archived facts = %+v\", view.Archives)\n\t}\n\tif len(view.Scopes) != 3 {\n\t\tt.Fatalf(\"Memory() scopes = %+v, want user/project/local\", view.Scopes)\n\t}\n}\n\nfunc TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tconsumeSystemQuitRequested()\n\tt.Cleanup(func() { consumeSystemQuitRequested() })\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmarkSystemQuitRequested()\n\tif prevent := NewApp().beforeClose(context.Background()); prevent {\n\t\tt.Fatal(\"system quit should bypass background close-to-tray behavior\")\n\t}\n\tif consumeSystemQuitRequested() {\n\t\tt.Fatal(\"system quit marker should be consumed by beforeClose\")\n\t}\n}\n\nfunc TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\twant bool\n\t}{\n\t\t{goos: \"darwin\", want: true},\n\t\t{goos: \"windows\", want: false},\n\t\t{goos: \"linux\", want: false},\n\t\t{goos: \"freebsd\", want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundCloseUsesApplicationHide(%q) = %v, want %v\", tt.goos, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestoreMaximiseStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tgoos string\n\t\tmaximised bool\n\t\twant bool\n\t}{\n\t\t{goos: \"windows\", maximised: true, want: true},\n\t\t{goos: \"linux\", maximised: true, want: true},\n\t\t{goos: \"darwin\", maximised: true, want: false},\n\t\t{goos: \"windows\", maximised: false, want: false},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {\n\t\t\tt.Fatalf(\"backgroundRestoreShouldMaximise(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tgoos string\n\t\tmaximised bool\n\t\twant backgroundRestorePlan\n\t}{\n\t\t{\n\t\t\tname: \"maximised Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: true,\n\t\t\twant: backgroundRestorePlan{maximiseBeforeShow: true},\n\t\t},\n\t\t{\n\t\t\tname: \"normal Windows window\",\n\t\t\tgoos: \"windows\",\n\t\t\tmaximised: false,\n\t\t\twant: backgroundRestorePlan{unminimiseAfterShow: true},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := backgroundRestorePlanFor(tt.goos, tt.maximised)\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Fatalf(\"backgroundRestorePlanFor(%q, %v) = %v, want %v\", tt.goos, tt.maximised, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEmitReadyInvokesReadyHook(t *testing.T) {\n\tapp := NewApp()\n\tvar calls int32\n\tapp.readyHook = func() {\n\t\tatomic.AddInt32(&calls, 1)\n\t}\n\n\tapp.emitReady(context.TODO())\n\n\tif got := atomic.LoadInt32(&calls); got != 1 {\n\t\tt.Fatalf(\"ready hook calls = %d, want 1\", got)\n\t}\n}\n\nfunc TestSetEffortPersistsAndAutoClears(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n\tif err := app.SetEffort(\"auto\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(auto): %v\", err)\n\t}\n\tif got := app.Effort().Current; got != \"auto\" {\n\t\tt.Fatalf(\"Effort current = %q, want auto\", got)\n\t}\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read saved config: %v\", err)\n\t}\n\tif strings.Contains(string(body), `effort = \"max\"`) {\n\t\tt.Fatalf(\"auto should clear explicit max effort:\\n%s\", body)\n\t}\n}\n\nfunc TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"icon\"\nstatus_bar_items = [\"cost\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"classic\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopCloseBehavior(\"background\"); err != nil {\n\t\tt.Fatalf(\"set desktop close behavior: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarStyle(\"text\"); err != nil {\n\t\tt.Fatalf(\"set desktop status bar style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopStatusBarItems([]string{\"model\", \"balance\", \"cache\"}); err != nil {\n\t\tt.Fatalf(\"set desktop status bar items: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tif got.DesktopLanguage != \"en\" || got.DesktopLayoutStyle != \"classic\" || got.DesktopTheme != \"dark\" || got.DesktopThemeStyle != \"graphite\" || got.CloseBehavior != \"background\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs\", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)\n\t}\n\tif want := []string{\"model\", \"balance\", \"cache\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"desktop status bar items = %v, want user-level %v\", got.StatusBarItems, want)\n\t}\n}\n\nfunc TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"WORKSPACE_ONLY_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"workspace-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://workspace.example/v1\",\n\t\tModel: \"workspace-model\",\n\t\tAPIKeyEnv: \"WORKSPACE_ONLY_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"workspace-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"WORKSPACE_ONLY_KEY\", \"\")\n\tos.Unsetenv(\"WORKSPACE_ONLY_KEY\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"workspace-provider\" {\n\t\t\tif !p.KeySet {\n\t\t\t\tt.Fatalf(\"workspace provider keySet = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\tif !p.Configured {\n\t\t\t\tt.Fatalf(\"workspace provider configured = false, want true from active workspace .env: %+v\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"workspace provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tlaunch := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \".env\"), []byte(\"SHARED_SETTINGS_KEY=from-project\\n\"), 0o600); err != nil {\n\t\tt.Fatalf(\"write project env: %v\", err)\n\t}\n\tif _, err := config.SetCredential(\"SHARED_SETTINGS_KEY\", \"from-credentials\"); err != nil {\n\t\tt.Fatalf(\"SetCredential: %v\", err)\n\t}\n\tuserCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\tif err := userCfg.UpsertProvider(config.ProviderEntry{\n\t\tName: \"settings-provider\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://settings.example/v1\",\n\t\tModel: \"settings-model\",\n\t\tAPIKeyEnv: \"SHARED_SETTINGS_KEY\",\n\t}); err != nil {\n\t\tt.Fatalf(\"upsert provider: %v\", err)\n\t}\n\tuserCfg.Desktop.ProviderAccess = []string{\"settings-provider\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\tt.Setenv(\"SHARED_SETTINGS_KEY\", \"from-project\")\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(launch); err != nil {\n\t\tt.Fatalf(\"chdir launch: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.tabs = map[string]*WorkspaceTab{\"project\": {ID: \"project\", WorkspaceRoot: project}}\n\tapp.activeTabID = \"project\"\n\tgot := app.Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"settings-provider\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.KeySet || p.KeySource != \"Reasonix credentials\" {\n\t\t\tt.Fatalf(\"settings-provider key = set:%v source:%q, want Reasonix credentials: %+v\", p.KeySet, p.KeySource, p)\n\t\t}\n\t\tif env := os.Getenv(\"SHARED_SETTINGS_KEY\"); env != \"from-project\" {\n\t\t\tt.Fatalf(\"Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env\", env)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings provider missing from settings: %+v\", got.Providers)\n}\n\nfunc TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tproject := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(project, \"reasonix.toml\"), []byte(`\ndefault_model = \"legacy-provider/legacy-model\"\n\n[desktop]\nlanguage = \"zh\"\nlayout_style = \"workbench\"\ntheme = \"light\"\ntheme_style = \"glacier\"\nclose_behavior = \"quit\"\nstatus_bar_style = \"text\"\nstatus_bar_items = [\"model\", \"cache\", \"balance\"]\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\torig, _ := os.Getwd()\n\tdefer func() { _ = os.Chdir(orig) }()\n\tif err := os.Chdir(project); err != nil {\n\t\tt.Fatalf(\"chdir project: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tgot := app.Settings()\n\tif got.ConfigPath != config.UserConfigPath() {\n\t\tt.Fatalf(\"Settings configPath = %q, want user config %q\", got.ConfigPath, config.UserConfigPath())\n\t}\n\tif got.DefaultModel != \"legacy-provider/legacy-model\" || got.DesktopLanguage != \"zh\" || got.DesktopLayoutStyle != \"workbench\" || got.DesktopTheme != \"light\" || got.DesktopThemeStyle != \"glacier\" || got.CloseBehavior != \"quit\" || got.StatusBarStyle != \"text\" {\n\t\tt.Fatalf(\"Settings did not seed from legacy project config: %+v\", got)\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(got.StatusBarItems, want) {\n\t\tt.Fatalf(\"Settings did not seed status bar items from legacy project config: got %v want %v\", got.StatusBarItems, want)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"Settings() should not write user config before an edit, stat err = %v\", err)\n\t}\n\tif err := app.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"SetDesktopLanguage: %v\", err)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif userCfg.DesktopLanguage() != \"en\" || userCfg.DesktopLayoutStyle() != \"workbench\" || userCfg.DesktopTheme() != \"light\" || userCfg.DesktopThemeStyle() != \"glacier\" || userCfg.DesktopCloseBehavior() != \"quit\" || userCfg.DesktopStatusBarStyle() != \"text\" {\n\t\tt.Fatalf(\"saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q\", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())\n\t}\n\tif want := []string{\"model\", \"cache\", \"balance\"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {\n\t\tt.Fatalf(\"saved user config did not preserve seeded status bar items: got %v want %v\", userCfg.DesktopStatusBarItems(), want)\n\t}\n}\n\nfunc TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tif err := app.SetSubagentModel(\"deepseek/deepseek-v4-pro\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentModel: %v\", err)\n\t}\n\tif err := app.SetSubagentEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetSubagentEffort: %v\", err)\n\t}\n\n\tgot := app.Settings()\n\tif got.SubagentModel != \"deepseek/deepseek-v4-pro\" || got.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"subagent settings = model:%q effort:%q\", got.SubagentModel, got.SubagentEffort)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.SubagentModel != \"deepseek/deepseek-v4-pro\" || cfg.Agent.SubagentEffort != \"max\" {\n\t\tt.Fatalf(\"saved config = model:%q effort:%q\", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)\n\t}\n}\n\nfunc TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tgot := NewApp().Settings()\n\tproviders := providerAccessSet(providerNamesFromView(got.Providers))\n\tofficial := providerAccessSet(providerNamesFromView(got.OfficialProviders))\n\tif providers[\"mimo-api\"] {\n\t\tt.Fatalf(\"mimo-api should not be mixed into configured providers: %+v\", got.Providers)\n\t}\n\tif !official[\"deepseek\"] || !official[\"mimo-api\"] || !official[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"official providers = %+v, want deepseek, mimo-api, and mimo-token-plan\", got.OfficialProviders)\n\t}\n}\n\nfunc TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !p.BuiltIn {\n\t\t\tt.Fatalf(\"deepseek provider should be marked built-in for official endpoint: %+v\", p)\n\t\t}\n\t\tif !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"deepseek provider = %+v, want added repaired official model list\", p)\n\t\t}\n\t\tif got.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", got.DefaultModel)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-Flash\"\n\n[desktop]\nprovider_access = [\"deepseek\"]\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nmodels = [\"deepseek-v4-Flash\", \"deepseek-v4-pro\", \"glm-5\"]\ndefault = \"deepseek-v4-Flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tvar custom *ProviderView\n\tfor i := range got.Providers {\n\t\tif got.Providers[i].Name == \"deepseek\" {\n\t\t\tcustom = &got.Providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif custom == nil {\n\t\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n\t}\n\tif custom.BuiltIn {\n\t\tt.Fatalf(\"external deepseek endpoint should be custom, got built-in provider: %+v\", *custom)\n\t}\n\tif !custom.Added || !custom.KeySet || custom.BaseURL != \"https://opencode.ai/zen/go/v1\" {\n\t\tt.Fatalf(\"external deepseek provider = %+v, want added key-set custom opencode endpoint\", *custom)\n\t}\n\tfor _, p := range got.OfficialProviders {\n\t\tif p.Name == \"deepseek\" && p.Added {\n\t\t\tt.Fatalf(\"official DeepSeek template should not be marked added by external endpoint: %+v\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added key-set provider\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added key-set provider\", providers[\"mimo-token-plan\"])\n\t}\n\tif got.DefaultModel != \"deepseek/deepseek-v4-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-pro\", got.DefaultModel)\n\t}\n}\n\nfunc TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added when provider_access is explicit empty\", p)\n\t\t}\n\t}\n}\n\nfunc TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tgot := NewApp().Settings()\n\tproviders := map[string]ProviderView{}\n\tfor _, p := range got.Providers {\n\t\tproviders[p.Name] = p\n\t}\n\tif !providers[\"deepseek\"].Added || !providers[\"deepseek\"].KeySet {\n\t\tt.Fatalf(\"deepseek provider = %+v, want inferred added provider from configured key\", providers[\"deepseek\"])\n\t}\n\tif !providers[\"mimo-token-plan\"].Added || !providers[\"mimo-token-plan\"].KeySet {\n\t\tt.Fatalf(\"mimo-token-plan provider = %+v, want inferred added provider from configured key\", providers[\"mimo-token-plan\"])\n\t}\n}\n\nfunc TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Added {\n\t\t\tt.Fatalf(\"provider %+v should not be inferred as added without a configured key\", p)\n\t\t}\n\t}\n}\n\nfunc TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"test-key\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tif len(p.Models) != 2 || p.Models[0] != \"deepseek-v4-flash\" || p.Models[1] != \"deepseek-v4-pro\" || p.Default != \"deepseek-v4-flash\" {\n\t\tt.Fatalf(\"deepseek provider after add = %+v, want official model list\", p)\n\t}\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access missing deepseek: %+v\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"default_model = %q, want deepseek/deepseek-v4-flash\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tos.Unsetenv(\"DEEPSEEK_API_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"DEEPSEEK_API_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif !providerAccessSet(cfg.Desktop.ProviderAccess)[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want deepseek restored\", cfg.Desktop.ProviderAccess)\n\t}\n\tgot := NewApp().Settings()\n\tfor _, p := range got.Providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tif !p.Added || !p.KeySet {\n\t\t\t\tt.Fatalf(\"deepseek settings = %+v, want added and key-set\", p)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"settings providers missing deepseek: %+v\", got.Providers)\n}\n\nfunc TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"PROXY_DEEPSEEK_KEY\", \"\")\n\tos.Unsetenv(\"PROXY_DEEPSEEK_KEY\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nprovider_access = []\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://proxy.example/v1\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"PROXY_DEEPSEEK_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().SetProviderKey(\"PROXY_DEEPSEEK_KEY\", \"sk-test\"); err != nil {\n\t\tt.Fatalf(\"SetProviderKey: %v\", err)\n\t}\n\tcfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif !access[\"deepseek-flash\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want custom alias deepseek-flash\", cfg.Desktop.ProviderAccess)\n\t}\n\tif access[\"deepseek\"] {\n\t\tt.Fatalf(\"provider_access = %+v, should not canonicalize custom proxy to deepseek\", cfg.Desktop.ProviderAccess)\n\t}\n}\n\nfunc TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\n[desktop]\nlanguage = \"zh\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif _, err := NewApp().AddOfficialProviderAccess(\"deepseek\", \"\"); err != nil {\n\t\tt.Fatalf(\"AddOfficialProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tp, ok := cfg.Provider(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"deepseek provider not saved\")\n\t}\n\tflash := p.Prices[\"deepseek-v4-flash\"]\n\tpro := p.Prices[\"deepseek-v4-pro\"]\n\tif flash == nil || flash.Output != 2 || flash.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash)\n\t}\n\tif pro == nil || pro.Output != 6 || pro.Currency != \"¥\" {\n\t\tt.Fatalf(\"pro price = %+v, want CNY preset\", pro)\n\t}\n}\n\nfunc TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"deepseek-flash/deepseek-v4-pro\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\", \"mimo-pro\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\"\napi_key_env = \"MIMO_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tif err := NewApp().RemoveProviderAccess(\"deepseek\"); err != nil {\n\t\tt.Fatalf(\"RemoveProviderAccess: %v\", err)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tif access[\"deepseek\"] || !access[\"mimo-token-plan\"] {\n\t\tt.Fatalf(\"provider_access = %+v, want only mimo-token-plan\", cfg.Desktop.ProviderAccess)\n\t}\n\tif cfg.DefaultModel != \"mimo-token-plan/mimo-v2.5-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want mimo-token-plan/mimo-v2.5-pro\", cfg.DefaultModel)\n\t}\n}\n\nfunc TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tdeepseek, _ := cfg.Provider(\"deepseek-flash\")\n\tdeepseek.Model = \"\"\n\tdeepseek.Models = []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tdeepseek.Default = \"deepseek-v4-flash\"\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tfor _, hidden := range []string{\n\t\t\"deepseek-pro/deepseek-v4-pro\",\n\t\t\"mimo-flash/mimo-v2.5\",\n\t} {\n\t\tif refs[hidden] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, should not include hidden provider %s\", models, hidden)\n\t\t}\n\t}\n\tif len(models) != 4 {\n\t\tt.Fatalf(\"Models() len = %d, want 4: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"LOCAL_API_KEY\", \"sk-test\")\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 2 {\n\t\tt.Fatalf(\"Models() len = %d, want 2: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tif err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir: %v\", err)\n\t}\n\tif err := os.WriteFile(config.UserConfigPath(), []byte(`\ndefault_model = \"local/model-a\"\n\n[desktop]\nprovider_access = [\"local\"]\n\n[[providers]]\nname = \"local\"\nkind = \"openai\"\nbase_url = \"http://127.0.0.1:23333/v1\"\nmodels = [\"model-a\", \"model-b\"]\ndefault = \"model-a\"\napi_key_env = \"LOCAL_API_KEY\"\n`), 0o644); err != nil {\n\t\tt.Fatalf(\"write config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\"local/model-a\", \"local/model-b\"} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"mimo-api/mimo-v2.5-pro\"\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tmodels := NewApp().Models()\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"mimo-api/mimo-v2.5-pro\",\n\t\t\"mimo-api/mimo-v2.5\",\n\t\t\"mimo-api/mimo-v2-omni\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"Models() refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n\tif len(models) != 3 {\n\t\tt.Fatalf(\"Models() len = %d, want 3: %+v\", len(models), models)\n\t}\n}\n\nfunc TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-token-plan/mimo-v2.5-pro\"\n\tuserCfg.Desktop.ProviderAccess = []string{\"deepseek-flash\", \"mimo-pro\"}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tprojectRoot := t.TempDir()\n\tprojectConfig := `default_model = \"deepseek-flash/deepseek-v4-flash\"\n\n[desktop]\nprovider_access = [\"deepseek-flash\"]\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodel = \"deepseek-v4-flash\"\napi_key_env = \"DEEPSEEK_API_KEY\"\n`\n\tif err := os.WriteFile(filepath.Join(projectRoot, \"reasonix.toml\"), []byte(projectConfig), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", WorkspaceRoot: projectRoot, Ready: true}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tmodels := app.ModelsForTab(tab.ID)\n\trefs := modelRefsFromView(models)\n\tfor _, want := range []string{\n\t\t\"deepseek/deepseek-v4-flash\",\n\t\t\"mimo-token-plan/mimo-v2.5-pro\",\n\t\t\"mimo-token-plan/mimo-v2.5\",\n\t} {\n\t\tif !refs[want] {\n\t\t\tt.Fatalf(\"ModelsForTab refs = %+v, missing %s\", models, want)\n\t\t}\n\t}\n}\n\nfunc TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\tt.Setenv(\"MIMO_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek-flash/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek-flash\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetModelForTab(tab.ID, \"mimo-flash/mimo-v2.5\")\n\tif err == nil || !strings.Contains(err.Error(), \"not available\") {\n\t\tt.Fatalf(\"SetModelForTab hidden provider error = %v, want not available\", err)\n\t}\n}\n\nfunc TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tcfg := config.Default()\n\tcfg.Desktop.ProviderAccess = []string{\"mimo-api\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ready: true, model: \"deepseek-flash/deepseek-v4-flash\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\terr := app.SetDefaultModel(\"mimo-api/mimo-v2.5-pro\")\n\tif err == nil || !strings.Contains(err.Error(), \"has no key\") {\n\t\tt.Fatalf(\"SetDefaultModel no-key error = %v, want has no key\", err)\n\t}\n\tif tab.model != \"deepseek-flash/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model after failed default change = %q, want previous\", tab.model)\n\t}\n}\n\nfunc TestSaveProviderPersistsReasoningProtocol(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tif err := app.SaveProvider(ProviderView{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://proxy.example.com/v1\",\n\t\tModels: []string{\"deepseek-v4-flash\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_PROXY_KEY\",\n\t\tReasoningProtocol: \"none\",\n\t\tSupportedEfforts: []string{\"high\", \"max\"},\n\t\tDefaultEffort: \"max\",\n\t}); err != nil {\n\t\tt.Fatalf(\"SaveProvider: %v\", err)\n\t}\n\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tgot, ok := cfg.Provider(\"deepseek-proxy\")\n\tif !ok {\n\t\tt.Fatal(\"saved provider not found\")\n\t}\n\tif got.ReasoningProtocol != \"none\" || got.DefaultEffort != \"max\" {\n\t\tt.Fatalf(\"saved provider = %+v, want reasoning_protocol none and default_effort max\", got)\n\t}\n\n\tview := app.Settings()\n\tfor _, p := range view.Providers {\n\t\tif p.Name == \"deepseek-proxy\" {\n\t\t\tif p.ReasoningProtocol != \"none\" {\n\t\t\t\tt.Fatalf(\"settings reasoningProtocol = %q, want none\", p.ReasoningProtocol)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"Settings() missing saved provider: %+v\", view.Providers)\n}\n\nfunc TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a2\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", Models: []string{\"model-a1\", \"model-a2\"}, APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tcfg.Agent.PlannerModel = \"prov-a\"\n\tcfg.Desktop.ProviderAccess = []string{\"prov-a\", \"prov-b\"}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tctrl := control.New(control.Options{Label: \"old\"})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"tab_a\", Scope: \"global\", Ctrl: ctrl, Label: \"prov-a/model-a1\", Ready: true, model: \"prov-a/model-a1\"}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.tabOrder = []string{tab.ID}\n\tapp.activeTabID = tab.ID\n\n\tif err := app.DeleteProvider(\"prov-a\"); err != nil {\n\t\tt.Fatalf(\"DeleteProvider: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif _, ok := got.Provider(\"prov-a\"); ok {\n\t\tt.Fatal(\"prov-a should be removed\")\n\t}\n\tif got.DefaultModel != \"prov-b\" || got.Agent.PlannerModel != \"prov-b\" {\n\t\tt.Fatalf(\"model refs after delete = default:%q planner:%q, want prov-b\", got.DefaultModel, got.Agent.PlannerModel)\n\t}\n\tif providerAccessSet(got.Desktop.ProviderAccess)[\"prov-a\"] {\n\t\tt.Fatalf(\"provider access still contains prov-a: %+v\", got.Desktop.ProviderAccess)\n\t}\n\tif tab.model != \"prov-b/model-b1\" || tab.Label != \"prov-b/model-b1\" {\n\t\tt.Fatalf(\"tab model after delete = model:%q label:%q, want prov-b/model-b1\", tab.model, tab.Label)\n\t}\n\tif tab.Ctrl != nil {\n\t\tt.Fatal(\"tab controller should be closed and cleared when retargeted without a running app context\")\n\t}\n}\n\nfunc TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"prov-a/model-a1\")\n\tctrl := app.activeCtrl()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"DeleteProvider while running error = %v, want finish/cancel guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n\tctrl.Close()\n}\n\nfunc TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"REASONIX_TEST_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"prov-a/model-a1\"\n\tcfg.Providers = []config.ProviderEntry{\n\t\t{Name: \"prov-a\", Kind: \"openai\", BaseURL: \"https://a.example.com\", Model: \"model-a1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t\t{Name: \"prov-b\", Kind: \"openai\", BaseURL: \"https://b.example.com\", Model: \"model-b1\", APIKeyEnv: \"REASONIX_TEST_KEY\"},\n\t}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"provider-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"prov-a/model-a1\")\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"provider job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.DeleteProvider(\"prov-a\")\n\tif err == nil || !strings.Contains(err.Error(), \"active work\") {\n\t\tt.Fatalf(\"DeleteProvider with background job error = %v, want active-work guard\", err)\n\t}\n\tif _, ok := config.LoadForEdit(config.UserConfigPath()).Provider(\"prov-a\"); !ok {\n\t\tt.Fatal(\"provider should remain after rejected deletion\")\n\t}\n}\n\nfunc TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tif err := userCfg.SetDesktopLanguage(\"en\"); err != nil {\n\t\tt.Fatalf(\"set desktop language: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopLayoutStyle(\"workbench\"); err != nil {\n\t\tt.Fatalf(\"set desktop layout style: %v\", err)\n\t}\n\tif err := userCfg.SetDesktopAppearance(\"dark\", \"graphite\"); err != nil {\n\t\tt.Fatalf(\"set desktop appearance: %v\", err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save user config: %v\", err)\n\t}\n\n\tif err := NewApp().MigrateDesktopPreferences(\"zh\", \"light\", \"glacier\"); err != nil {\n\t\tt.Fatalf(\"migrate desktop preferences: %v\", err)\n\t}\n\n\tgot := config.LoadForEdit(config.UserConfigPath())\n\tif got.DesktopLanguage() != \"en\" || got.DesktopLayoutStyle() != \"workbench\" || got.DesktopTheme() != \"dark\" || got.DesktopThemeStyle() != \"graphite\" {\n\t\tt.Fatalf(\"desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved\", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())\n\t}\n}\n\nfunc TestSetEffortRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetEffort should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetEffort should rebuild the active controller so the provider sees the new effort\")\n\t}\n\tif got := app.Effort().Current; got != \"max\" {\n\t\tt.Fatalf(\"Effort current = %q, want max\", got)\n\t}\n}\n\nfunc TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetEffort(\"max\"); err != nil {\n\t\tt.Fatalf(\"SetEffort(max): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n}\n\nfunc TestSetTokenModeRebuildsController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\tif c := app.activeCtrl(); c == nil {\n\t\tt.Fatal(\"SetTokenMode should leave a rebuilt controller\")\n\t}\n\tif c := app.activeCtrl(); c == old {\n\t\tt.Fatal(\"SetTokenMode should rebuild the active controller so the provider sees the new tool profile\")\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"economy\" {\n\t\tt.Fatalf(\"Meta token mode = %q, want economy\", got)\n\t}\n\tsaved := loadTabsFile()\n\tif len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != \"economy\" {\n\t\tt.Fatalf(\"saved tabs = %+v, want economy token mode\", saved.Tabs)\n\t}\n}\n\nfunc TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-test\")\n\n\tcfg := config.Default()\n\tcfg.DefaultModel = \"deepseek/deepseek-v4-flash\"\n\tcfg.Desktop.ProviderAccess = []string{\"deepseek\"}\n\tcfg.Providers = []config.ProviderEntry{{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModel: \"glm-5\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"deepseek-flash/deepseek-v4-flash\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetTokenMode(\"economy\"); err != nil {\n\t\tt.Fatalf(\"SetTokenMode(economy): %v\", err)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif tab.model != \"deepseek/deepseek-v4-flash\" {\n\t\tt.Fatalf(\"tab model = %q, want migrated official ref\", tab.model)\n\t}\n\tif got := currentTabTokenMode(tab); got != \"economy\" {\n\t\tt.Fatalf(\"token mode = %q, want economy\", got)\n\t}\n}\n\nfunc TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.readyHook = func() {}\n\told := control.New(control.Options{Label: \"old-controller\"})\n\tapp.setTestCtrl(old, \"missing-token-mode-model\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil {\n\t\tt.Fatal(\"SetTokenMode(economy) with an unknown model should fail\")\n\t}\n\tif c := app.activeCtrl(); c != old {\n\t\tt.Fatalf(\"SetTokenMode failure replaced controller: got %p want %p\", c, old)\n\t}\n\ttab := app.activeTab()\n\tif tab == nil {\n\t\tt.Fatal(\"active tab missing\")\n\t}\n\tif got := currentTabTokenMode(tab); got != \"full\" {\n\t\tt.Fatalf(\"token mode after failed rebuild = %q, want full\", got)\n\t}\n\tif got := app.Meta().TokenMode; got != \"full\" {\n\t\tt.Fatalf(\"Meta token mode after failed rebuild = %q, want full\", got)\n\t}\n}\n\nfunc TestSetEffortRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetEffort(\"max\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetEffort while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsRunningTurn(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Runner: runner}), \"\")\n\tapp.activeCtrl().Submit(\"work\")\n\t<-runner.started\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"finish or cancel\") {\n\t\tt.Fatalf(\"SetTokenMode while running error = %v, want finish/cancel guard\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, app.activeCtrl())\n}\n\nfunc TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"jobs.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\trelease := make(chan struct{})\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"long job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn \"\", ctx.Err()\n\t\tcase <-release:\n\t\t\treturn \"\", nil\n\t\t}\n\t})\n\tdefer close(release)\n\n\terr := app.SetTokenMode(\"economy\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetTokenMode with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"settings-job.jsonl\")\n\tjm := jobs.NewManager(event.Discard)\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\", Jobs: jm})\n\tdefer ctrl.Close()\n\tapp := NewApp()\n\tapp.ctx = context.Background()\n\tapp.setTestCtrl(ctrl, \"deepseek-flash/deepseek-v4-flash\")\n\n\tjm.StartForSession(agent.BranchID(path), \"bash\", \"settings job\", func(ctx context.Context, _ io.Writer) (string, error) {\n\t\t<-ctx.Done()\n\t\treturn \"\", ctx.Err()\n\t})\n\n\terr := app.SetSandbox(\"enforce\", true, \"\", nil, \"\")\n\tif err == nil || !strings.Contains(err.Error(), \"stop background jobs\") {\n\t\tt.Fatalf(\"SetSandbox with background job error = %v, want background-job guard\", err)\n\t}\n}\n\nfunc TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"clear-running.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"old\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\toldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.projectTreeChangedHook = func() {}\n\tapp.setTestCtrl(oldCtrl, \"deepseek-flash/deepseek-v4-flash\")\n\tapp.tabs[\"test\"].TopicID = \"topic_clear\"\n\tapp.tabs[\"test\"].TopicTitle = \"Clear topic\"\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\toldCtrl.Submit(\"work\")\n\t<-runner.started\n\tif err := app.ClearSession(); err != nil {\n\t\tt.Fatalf(\"ClearSession: %v\", err)\n\t}\n\twaitNotRunning(t, oldCtrl)\n\ttab := app.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\tt.Fatalf(\"active tab/controller missing after clear\")\n\t}\n\tif tab.Ctrl == oldCtrl {\n\t\tt.Fatalf(\"clear should replace the active controller after cancelling old work\")\n\t}\n\tif tab.TopicID != \"topic_clear\" || tab.TopicTitle != \"Clear topic\" {\n\t\tt.Fatalf(\"clear changed topic identity: %+v\", tab)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"old cleared session artifacts should be removed, stat err = %v\", err)\n\t}\n\tif got := tab.currentSessionPath(); got == \"\" || got == path {\n\t\tt.Fatalf(\"new session path = %q, want fresh path\", got)\n\t}\n}\n\nfunc TestSearchFileRefsFindsNestedBasename(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tdir := robustTempDir(t)\n\tif err := os.MkdirAll(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\"), []byte(\"x\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \"Thumbs.db\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"frontend\", \".DS_Store\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"node_modules\", \"pkg\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"node_modules\", \"pkg\", \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, noise := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := os.WriteFile(filepath.Join(dir, noise, \"runtime.js\"), []byte(\"noise\"), 0o644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"desktop\", \"frontend\", \"wailsjs\", \"runtime.js\"), []byte(\"generated\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, \"product\", \"bin\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"product\", \"bin\", \"runtime.js\"), []byte(\"real\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(dir); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := &App{}\n\tlisted := app.ListDir(\"\")\n\tfor _, hidden := range []string{\".codex\", \".npm\", \".pnpm-store\", \"bin\", \"dist\", \"stage\", \"tmp\"} {\n\t\tif hasDirEntry(listed, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise %q, got %+v\", hidden, listed)\n\t\t}\n\t}\n\tdesktopFrontend := app.ListDir(\"desktop/frontend\")\n\tif hasDirEntry(desktopFrontend, \"wailsjs\") {\n\t\tt.Fatalf(\"ListDir should hide generated Wails bindings, got %+v\", desktopFrontend)\n\t}\n\tfrontendEntries := app.ListDir(\"frontend\")\n\tfor _, hidden := range []string{\".DS_Store\", \"Thumbs.db\"} {\n\t\tif hasDirEntry(frontendEntries, hidden) {\n\t\t\tt.Fatalf(\"ListDir should hide local noise file %q, got %+v\", hidden, frontendEntries)\n\t\t}\n\t}\n\n\tgot := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(got, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs(runtime.js) should find nested workspace file, got %+v\", got)\n\t}\n\tif !hasDirEntry(got, \"product/bin/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should keep non-root bin directories searchable, got %+v\", got)\n\t}\n\tif hasDirEntry(got, \"node_modules/pkg/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip node_modules noise, got %+v\", got)\n\t}\n\tfor _, hidden := range []string{\n\t\t\".codex/runtime.js\",\n\t\t\".npm/runtime.js\",\n\t\t\".pnpm-store/runtime.js\",\n\t\t\"bin/runtime.js\",\n\t\t\"desktop/frontend/wailsjs/runtime.js\",\n\t\t\"dist/runtime.js\",\n\t\t\"stage/runtime.js\",\n\t\t\"tmp/runtime.js\",\n\t} {\n\t\tif hasDirEntry(got, hidden) {\n\t\t\tt.Fatalf(\"SearchFileRefs should skip local noise %q, got %+v\", hidden, got)\n\t\t}\n\t}\n\tif noise := app.SearchFileRefs(\"Thumbs\"); hasDirEntry(noise, \"frontend/Thumbs.db\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip Thumbs.db noise, got %+v\", noise)\n\t}\n\tif noise := app.SearchFileRefs(\".DS\"); hasDirEntry(noise, \"frontend/.DS_Store\") {\n\t\tt.Fatalf(\"SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v\", noise)\n\t}\n}\n\nfunc TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {\n\torig, _ := os.Getwd()\n\tdefer os.Chdir(orig)\n\n\tlaunchRoot := robustTempDir(t)\n\tprojectRoot := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(launchRoot, \"launch-only.txt\"), []byte(\"wrong\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tprojectFile := filepath.Join(projectRoot, \"frontend\", \"wailsjs\", \"runtime\", \"runtime.js\")\n\tif err := os.WriteFile(projectFile, []byte(\"right workspace\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chdir(launchRoot); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\ttab := &WorkspaceTab{ID: \"project\", Scope: \"project\", WorkspaceRoot: projectRoot}\n\tapp.tabs = map[string]*WorkspaceTab{tab.ID: tab}\n\tapp.activeTabID = tab.ID\n\n\tlisted := app.ListDir(\"\")\n\tif !hasDirEntry(listed, \"frontend\") {\n\t\tt.Fatalf(\"ListDir should list active project root, got %+v\", listed)\n\t}\n\tif hasDirEntry(listed, \"launch-only.txt\") {\n\t\tt.Fatalf(\"ListDir leaked launch cwd entries, got %+v\", listed)\n\t}\n\n\tfound := app.SearchFileRefs(\"runtime.js\")\n\tif !hasDirEntry(found, \"frontend/wailsjs/runtime/runtime.js\") {\n\t\tt.Fatalf(\"SearchFileRefs should search active project root, got %+v\", found)\n\t}\n\tpreview := app.ReadFile(\"frontend/wailsjs/runtime/runtime.js\")\n\tif preview.Err != \"\" || preview.Body != \"right workspace\" {\n\t\tt.Fatalf(\"ReadFile active project preview = %+v, want project file\", preview)\n\t}\n}\n\nfunc TestDeleteSessionCancelsActiveRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tkeepPath := filepath.Join(dir, \"keep.jsonl\")\n\tif err := os.WriteFile(keepPath, []byte(`{\"role\":\"user\",\"content\":\"keep\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write keep session: %v\", err)\n\t}\n\tkeepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: \"keep\"})\n\tdefer keepCtrl.Close()\n\tapp.setTestCtrl(activeCtrl, \"\")\n\tapp.tabs[\"keep\"] = &WorkspaceTab{ID: \"keep\", Scope: \"global\", Ctrl: keepCtrl, Ready: true}\n\tapp.tabOrder = []string{\"test\", \"keep\"}\n\n\tif err := app.DeleteSession(filepath.Base(path)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(active basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"test\"]; ok {\n\t\tt.Fatalf(\"deleted active session runtime should be removed\")\n\t}\n\tif got := app.activeTabID; got != \"keep\" {\n\t\tt.Fatalf(\"active tab after delete = %q, want keep\", got)\n\t}\n\tif _, err := os.Stat(path); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"active session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"active.jsonl\", \"active.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"active session should be moved to trash: %v\", err)\n\t}\n}\n\nfunc TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"active-conflict.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {\n\t\tt.Fatalf(\"create trash conflict: %v\", err)\n\t}\n\n\trunner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}\n\tctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: \"test\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer ctrl.Close()\n\tctrl.Submit(\"work\")\n\t<-runner.started\n\n\terr := app.DeleteSession(filepath.Base(path))\n\tif err == nil || !strings.Contains(err.Error(), \"already exists in trash\") {\n\t\tt.Fatalf(\"DeleteSession conflict error = %v, want trash conflict\", err)\n\t}\n\tif app.activeCtrl() != ctrl {\n\t\tt.Fatalf(\"active runtime should remain bound after preflight failure\")\n\t}\n\tif !ctrl.Running() {\n\t\tt.Fatalf(\"running turn should not be cancelled on preflight failure\")\n\t}\n\tif _, ok := app.tabs[\"test\"]; !ok {\n\t\tt.Fatalf(\"tab should remain after preflight failure\")\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"active session file should remain: %v\", err)\n\t}\n\n\tclose(runner.release)\n\twaitNotRunning(t, ctrl)\n}\n\nfunc TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tactivePath := filepath.Join(dir, \"active.jsonl\")\n\tinactivePath := filepath.Join(dir, \"inactive.jsonl\")\n\totherPath := filepath.Join(dir, \"other.jsonl\")\n\tfor _, path := range []string{activePath, inactivePath, otherPath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write session %s: %v\", path, err)\n\t\t}\n\t}\n\n\tactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: \"active\"})\n\tinactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: \"inactive\"})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tif err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {\n\t\tt.Fatalf(\"DeleteSession(inactive open basename): %v\", err)\n\t}\n\tif _, ok := app.tabs[\"inactive\"]; ok {\n\t\tt.Fatalf(\"deleted inactive session runtime should be removed\")\n\t}\n\tif _, err := os.Stat(inactivePath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"inactive open session should be moved out of active history, stat err = %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, \"inactive.jsonl\", \"inactive.jsonl\")\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"inactive open session should be moved to trash: %v\", err)\n\t}\n\n\tsessions := app.ListSessions()\n\tcurrent := map[string]bool{}\n\topen := map[string]bool{}\n\tfor _, s := range sessions {\n\t\tcurrent[filepath.Base(s.Path)] = s.Current\n\t\topen[filepath.Base(s.Path)] = s.Open\n\t}\n\tif !current[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active session current, got %#v\", current)\n\t}\n\tif current[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session current, got %#v\", current)\n\t}\n\tif !open[filepath.Base(activePath)] {\n\t\tt.Fatalf(\"ListSessions should mark active and inactive open sessions open, got %#v\", open)\n\t}\n\tif open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {\n\t\tt.Fatalf(\"ListSessions marked unopened session open, got %#v\", open)\n\t}\n}\n\nfunc TestRestoreSessionRejectsDestroyingSession(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tsessionPath := filepath.Join(dir, \"trash-me.jsonl\")\n\tif err := os.WriteFile(sessionPath, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tif err := deleteSessionFile(dir, sessionPath); err != nil {\n\t\tt.Fatalf(\"deleteSessionFile: %v\", err)\n\t}\n\ttrashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))\n\n\tjm := jobs.NewManager(event.Discard)\n\tdefer jm.Close()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"active\", Jobs: jm})\n\tdefer ctrl.Close()\n\tdestroy := ctrl.BeginDestroySession(sessionPath)\n\tdefer destroy.Finish()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"\")\n\tif err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), \"cleanup is still in progress\") {\n\t\tt.Fatalf(\"RestoreSession while destroying error = %v, want cleanup-in-progress\", err)\n\t}\n\tif _, err := os.Stat(trashPath); err != nil {\n\t\tt.Fatalf(\"trashed session should remain after rejected restore: %v\", err)\n\t}\n\n\tdestroy.Finish()\n\tif err := app.RestoreSession(trashPath); err != nil {\n\t\tt.Fatalf(\"RestoreSession after finish: %v\", err)\n\t}\n\tif _, err := os.Stat(sessionPath); err != nil {\n\t\tt.Fatalf(\"session should be restored: %v\", err)\n\t}\n}\n\nfunc TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdirA := filepath.Join(t.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(t.TempDir(), \"workspace-b-sessions\")\n\tif err := os.MkdirAll(dirA, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirA: %v\", err)\n\t}\n\tif err := os.MkdirAll(dirB, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir dirB: %v\", err)\n\t}\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathA: %v\", err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write pathB: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != \"workspace A\" {\n\t\tt.Fatalf(\"ListSessions should read the active controller session dir only, got %+v\", sessions)\n\t}\n\tif err := app.RenameSession(pathA, \"A title\"); err != nil {\n\t\tt.Fatalf(\"RenameSession in active session dir: %v\", err)\n\t}\n\tif titles := loadSessionTitles(dirA); titles[\"a.jsonl\"] != \"A title\" {\n\t\tt.Fatalf(\"title should be written beside the active session, got %+v\", titles)\n\t}\n\tif titles := loadSessionTitles(dirB); len(titles) != 0 {\n\t\tt.Fatalf(\"inactive workspace title sidecar should remain untouched, got %+v\", titles)\n\t}\n}\n\nfunc TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\tcfg := config.Default()\n\tcfg.Bot.Connections = []config.BotConnectionConfig{{\n\t\tID: \"weixin-weixin\", Provider: \"weixin\", Domain: \"weixin\", Label: \"微信\", Enabled: true, Status: \"connected\",\n\t\tSessionMappings: []config.BotConnectionSessionMapping{{\n\t\t\tRemoteID: \"wx-chat-1\", SessionID: \"path:\" + path, SessionSource: \"auto\",\n\t\t}},\n\t}}\n\tif err := cfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"save config: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tsessions := app.ListSessions()\n\tif len(sessions) != 1 {\n\t\tt.Fatalf(\"ListSessions len = %d, want 1: %+v\", len(sessions), sessions)\n\t}\n\tgot := sessions[0]\n\tif got.Kind != \"channel\" || got.Channel != \"weixin\" || got.ChannelLabel != \"微信\" || got.RemoteID != \"wx-chat-1\" || got.SessionSource != \"auto\" {\n\t\tt.Fatalf(\"channel session meta = %+v\", got)\n\t}\n}\n\nfunc TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := filepath.Join(dir, \"bot-channel.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"from channel\"}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write session: %v\", err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, \"active.jsonl\"), Label: \"test\"})\n\tapp.setTestCtrl(ctrl, \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.OpenChannelSessionForTab(\"test\", path); err != nil {\n\t\tt.Fatalf(\"OpenChannelSessionForTab: %v\", err)\n\t}\n\tif meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {\n\t\tt.Fatalf(\"channel tab should be read-only: %+v\", meta)\n\t}\n\tbefore, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read before: %v\", err)\n\t}\n\tapp.SubmitToTab(\"test\", \"must not append\")\n\tapp.RunShellForTab(\"test\", \"echo must-not-run\")\n\tafter, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after: %v\", err)\n\t}\n\tif string(after) != string(before) {\n\t\tt.Fatalf(\"read-only channel transcript changed:\\nbefore=%s\\nafter=%s\", before, after)\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"open append: %v\", err)\n\t}\n\tif _, err := f.WriteString(`{\"role\":\"user\",\"content\":\"external follow-up\"}` + \"\\n\"); err != nil {\n\t\tf.Close()\n\t\tt.Fatalf(\"append external message: %v\", err)\n\t}\n\tif err := f.Close(); err != nil {\n\t\tt.Fatalf(\"close append: %v\", err)\n\t}\n\tapp.snapshotAllTabs()\n\tafterSnapshot, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"read after snapshot: %v\", err)\n\t}\n\tif !strings.Contains(string(afterSnapshot), \"external follow-up\") {\n\t\tt.Fatalf(\"read-only channel snapshot overwrote external append:\\n%s\", afterSnapshot)\n\t}\n}\n\nfunc TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tactivePath := filepath.Join(dirA, \"active.jsonl\")\n\toutsidePath := filepath.Join(dirB, \"outside.jsonl\")\n\tfor _, path := range []string{activePath, outsidePath} {\n\t\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello\"}`+\"\\n\"), 0o644); err != nil {\n\t\t\tt.Fatalf(\"write %s: %v\", path, err)\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif _, err := app.ResumeSession(outsidePath); err == nil {\n\t\tt.Fatal(\"ResumeSession should reject a transcript outside the active session dir\")\n\t}\n\tif _, err := app.PreviewSession(outsidePath); err == nil {\n\t\tt.Fatal(\"PreviewSession should reject a transcript outside the active session dir\")\n\t}\n}\n\nfunc BenchmarkDesktopListSessionsScoped(b *testing.B) {\n\tdirA := filepath.Join(b.TempDir(), \"workspace-a-sessions\")\n\tdirB := filepath.Join(b.TempDir(), \"workspace-b-sessions\")\n\tfor _, dir := range []string{dirA, dirB} {\n\t\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\t\tb.Fatalf(\"mkdir %s: %v\", dir, err)\n\t\t}\n\t\tfor i := 0; i < 120; i++ {\n\t\t\tpath := filepath.Join(dir, fmt.Sprintf(\"session-%03d.jsonl\", i))\n\t\t\tbody := fmt.Sprintf(`{\"role\":\"user\",\"content\":\"session %03d\"}`+\"\\n\", i)\n\t\t\tif err := os.WriteFile(path, []byte(body), 0o644); err != nil {\n\t\t\t\tb.Fatalf(\"write session: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, \"session-000.jsonl\"), Label: \"test\"}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tb.ReportAllocs()\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsessions := app.ListSessions()\n\t\tif len(sessions) != 120 {\n\t\t\tb.Fatalf(\"ListSessions len = %d, want 120\", len(sessions))\n\t\t}\n\t}\n}\n\ntype appendingDesktopRunner struct {\n\tsession *agent.Session\n\tstarted chan string\n}\n\nfunc (r *appendingDesktopRunner) Run(_ context.Context, input string) error {\n\tr.started <- input\n\tr.session.Add(provider.Message{Role: provider.RoleUser, Content: input})\n\tr.session.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\treturn nil\n}\n\nfunc TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpath := filepath.Join(dir, \"memory-display.jsonl\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t})\n\tdefer ctrl.Close()\n\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tctrl.QueueMemory(`Saved memory \"reasonix-contributions\": contribution count updated`)\n\n\tconst prompt = \"不要,删了\"\n\tapp.SubmitToTab(\"test\", prompt)\n\tcomposed := <-runner.started\n\twaitNotRunning(t, ctrl)\n\n\tif !strings.Contains(composed, \"\") || !strings.HasSuffix(composed, prompt) {\n\t\tt.Fatalf(\"model input should include memory update followed by prompt, got %q\", composed)\n\t}\n\tgot := app.HistoryForTab(\"test\")\n\tif len(got) < 2 {\n\t\tt.Fatalf(\"history length = %d, want user + assistant\", len(got))\n\t}\n\tif got[0].Role != \"system\" || got[1].Role != \"user\" {\n\t\tt.Fatalf(\"history roles = %+v, want system then user\", got[:min(len(got), 2)])\n\t}\n\tif got[1].Content != prompt {\n\t\tt.Fatalf(\"displayed user content = %q, want %q\", got[1].Content, prompt)\n\t}\n\tif strings.Contains(got[1].Content, \"\") {\n\t\tt.Fatalf(\"displayed user content leaked memory update: %q\", got[1].Content)\n\t}\n}\n\nfunc TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tworkspace := robustTempDir(t)\n\tif err := os.WriteFile(filepath.Join(workspace, \"reasonix.toml\"), []byte(\"\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write workspace config: %v\", err)\n\t}\n\tdir := config.SessionDir()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir session dir: %v\", err)\n\t}\n\tpath := agent.NewSessionPath(dir, \"test\")\n\tsess := agent.NewSession(\"sys\")\n\texec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)\n\trunner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}\n\tctrl := control.New(control.Options{\n\t\tRunner: runner,\n\t\tExecutor: exec,\n\t\tSink: event.Discard,\n\t\tSessionDir: dir,\n\t\tSessionPath: path,\n\t\tLabel: \"test\",\n\t\tWorkspaceRoot: workspace,\n\t})\n\tapp := NewApp()\n\tapp.setTestCtrl(ctrl, \"deepseek/test\")\n\tapp.tabs[\"test\"].Scope = \"project\"\n\tapp.tabs[\"test\"].WorkspaceRoot = workspace\n\tapp.tabs[\"test\"].TopicID = \"topic_source\"\n\tapp.tabs[\"test\"].TopicTitle = \"Source topic\"\n\tdefer ctrl.Close()\n\n\tctrl.Submit(\"first\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tctrl.Submit(\"second\")\n\t<-runner.started\n\twaitNotRunning(t, ctrl)\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len before fork = %d, want 5\", got)\n\t}\n\n\tmeta, err := app.Fork(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Fork: %v\", err)\n\t}\n\tif !meta.Active || meta.ID == \"\" || meta.ID == \"test\" {\n\t\tt.Fatalf(\"fork meta = %+v, want a new active tab\", meta)\n\t}\n\tif got := app.activeTabID; got != meta.ID {\n\t\tt.Fatalf(\"active tab = %q, want fork tab %q\", got, meta.ID)\n\t}\n\tif got := ctrl.SessionPath(); got != path {\n\t\tt.Fatalf(\"source controller session path = %q, want %q\", got, path)\n\t}\n\tif got := len(ctrl.History()); got != 5 {\n\t\tt.Fatalf(\"source history len after fork = %d, want 5\", got)\n\t}\n\tif got, want := meta.TopicTitle, \"Source topic · 分叉\"; got != want {\n\t\tt.Fatalf(\"fork topic title = %q, want %q\", got, want)\n\t}\n\n\tvar forkPath string\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\tt.Fatalf(\"read session dir: %v\", err)\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tcandidate := filepath.Join(dir, entry.Name())\n\t\tif candidate == path {\n\t\t\tcontinue\n\t\t}\n\t\tm, ok, err := agent.LoadBranchMeta(candidate)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load fork meta: %v\", err)\n\t\t}\n\t\tif ok && m.TopicID == meta.TopicID {\n\t\t\tforkPath = candidate\n\t\t\tif m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {\n\t\t\t\tt.Fatalf(\"fork branch meta = %+v, want parent %q turn 1 index 3\", m, agent.BranchID(path))\n\t\t\t}\n\t\t\tif m.Scope != \"project\" || m.WorkspaceRoot != workspace || m.TopicTitle != \"Source topic · 分叉\" {\n\t\t\t\tt.Fatalf(\"fork topic meta = %+v\", m)\n\t\t\t}\n\t\t}\n\t}\n\tif forkPath == \"\" {\n\t\tt.Fatalf(\"fork session with topic %q not found in %s\", meta.TopicID, dir)\n\t}\n}\n\nfunc TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"default MCP status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"MCPServers() = %+v, want Capabilities().Servers %+v\", got, want)\n\t}\n}\n\nfunc TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfound := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name != \"time\" {\n\t\t\tcontinue\n\t\t}\n\t\tfound = true\n\t\tif s.BuiltIn || !s.Configured || s.Command != \"custom-time\" || !reflect.DeepEqual(s.Args, []string{\"serve\"}) {\n\t\t\tt.Fatalf(\"configured time view = %+v, want ordinary user MCP config\", s)\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"configured time server missing from Capabilities: %+v\", view.Servers)\n\t}\n\n\tif err := app.SetMCPServerEnabled(\"time\", false); err != nil {\n\t\tt.Fatalf(\"SetMCPServerEnabled(time,false): %v\", err)\n\t}\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"time\" {\n\t\t\tif s.Status != \"disabled\" || s.BuiltIn || s.Command != \"custom-time\" {\n\t\t\t\tt.Fatalf(\"disabled configured time view = %+v, want disabled external config\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"time missing after disable: %+v\", view.Servers)\n}\n\nfunc TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"time\"\ncommand = \"custom-time\"\nargs = [\"serve\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"time\", MCPServerInput{\n\t\tName: \"time\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"updated-time\",\n\t\tArgs: []string{\"run\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer(time): %v\", err)\n\t}\n\tcfg, err := config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tupdated, ok := findPluginEntry(cfg.Plugins, \"time\")\n\tif !ok || updated.Command != \"updated-time\" || !reflect.DeepEqual(updated.Args, []string{\"run\"}) {\n\t\tt.Fatalf(\"updated time plugin = %+v, found=%v\", updated, ok)\n\t}\n\n\tif err := app.RemoveMCPServer(\"time\"); err != nil {\n\t\tt.Fatalf(\"RemoveMCPServer(time): %v\", err)\n\t}\n\tcfg, err = config.LoadForRoot(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := findPluginEntry(cfg.Plugins, \"time\"); ok {\n\t\tt.Fatalf(\"time plugin still configured after remove: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"dida\"\ntype = \"http\"\nurl = \"https://mcp.dida365.com\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"dida\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" || s.AuthURL != \"https://mcp.dida365.com\" {\n\t\t\t\tt.Fatalf(\"dida auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"dida MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"stripe\"\ntype = \"http\"\nurl = \"https://mcp.stripe.com\"\nheaders = { Authorization = \"Bearer ${STRIPE_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"stripe\" {\n\t\t\tif s.AuthStatus != \"none\" {\n\t\t\t\tt.Fatalf(\"stripe auth status = %q, want none; server = %+v\", s.AuthStatus, s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"stripe MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"failed\" || s.AuthStatus != \"required\" || s.AuthURL != \"https://mcp.figma.com/mcp\" {\n\t\t\t\tt.Fatalf(\"figma auth diagnosis = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"figma\"\ntype = \"http\"\nurl = \"https://mcp.figma.com/mcp?access_token=abc&workspace=main\"\nheaders = { Authorization = \"Bearer ${FIGMA_TOKEN}\", \"X-Org\" = \"team\" }\nenv = { FIGMA_TOKEN = \"${FIGMA_TOKEN}\", DEBUG = \"1\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thost := plugin.NewHost()\n\thost.RecordFailure(plugin.Spec{Name: \"figma\", Type: \"http\", URL: \"https://mcp.figma.com/mcp\"}, errors.New(\"connect: 401 unauthorized\"))\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: host}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.ClearMCPServerAuthentication(\"figma\"); err != nil {\n\t\tt.Fatalf(\"ClearMCPServerAuthentication: %v\", err)\n\t}\n\tif failures := host.Failures(); len(failures) != 0 {\n\t\tt.Fatalf(\"failure should be cleared: %+v\", failures)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.URL != \"https://mcp.figma.com/mcp?workspace=main\" {\n\t\tt.Fatalf(\"url = %q\", p.URL)\n\t}\n\tif _, ok := p.Headers[\"Authorization\"]; ok {\n\t\tt.Fatalf(\"auth header should be removed: %v\", p.Headers)\n\t}\n\tif p.Headers[\"X-Org\"] != \"team\" {\n\t\tt.Fatalf(\"ordinary header should be preserved: %v\", p.Headers)\n\t}\n\tif _, ok := p.Env[\"FIGMA_TOKEN\"]; ok {\n\t\tt.Fatalf(\"auth env should be removed: %v\", p.Env)\n\t}\n\tif p.Env[\"DEBUG\"] != \"1\" {\n\t\tt.Fatalf(\"ordinary env should be preserved: %v\", p.Env)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"figma\" {\n\t\t\tif s.Status != \"initializing\" || s.AuthStatus != \"possible\" {\n\t\t\t\tt.Fatalf(\"figma should return to background possible auth: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"figma MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\nenv = { TOKEN = \"${PLAYWRIGHT_TOKEN}\" }\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"node\",\n\t\tArgs: []string{\"server.js\"},\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"node\" {\n\t\tt.Fatalf(\"updated command = %q, want node\", got)\n\t}\n\tif got := cfg.Plugins[0].Env[\"TOKEN\"]; got != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"env TOKEN = %q, want preserved env\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"playwright\")\n\tif !ok {\n\t\tt.Fatalf(\"playwright should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Command != \"node\" || userPlugin.Env[\"TOKEN\"] != \"${PLAYWRIGHT_TOKEN}\" {\n\t\tt.Fatalf(\"user plugin after migration = %+v\", userPlugin)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"playwright\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"playwright\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"node\" || len(s.Args) != 1 || s.Args[0] != \"server.js\" {\n\t\t\t\tt.Fatalf(\"server command not refreshed: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"playwright MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"playwright\"\ncommand = \"npx\"\nargs = [\"-y\", \"@playwright/mcp\"]\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"playwright\", MCPServerInput{\n\t\tName: \"playwright\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"npx -y @modelcontextprotocol/server-filesystem .\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tp := cfg.Plugins[0]\n\tif p.Command != \"npx\" {\n\t\tt.Fatalf(\"command = %q, want npx\", p.Command)\n\t}\n\tif got := strings.Join(p.Args, \"\\x00\"); got != strings.Join([]string{\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"}, \"\\x00\") {\n\t\tt.Fatalf(\"args = %v\", p.Args)\n\t}\n}\n\nfunc TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"npx\"\ntier = \"background\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tif err := app.UpdateMCPServer(\"broken\", MCPServerInput{\n\t\tName: \"broken\",\n\t\tTransport: \"stdio\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t}); err != nil {\n\t\tt.Fatalf(\"UpdateMCPServer should persist config even when reconnect fails: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Command; got != \"reasonix-missing-mcp-binary\" {\n\t\tt.Fatalf(\"updated command = %q, want missing binary\", got)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"updated tier = %q, want migrated empty\", got)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Command != \"reasonix-missing-mcp-binary\" || s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server config not refreshed after failed reconnect: %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"codegraph\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treg := tool.NewRegistry()\n\treg.Add(desktopFakeTool{name: \"mcp__codegraph__connect\"})\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), \"\")\n\tdefer app.activeCtrl().Close()\n\n\tview := app.Capabilities()\n\tfoundInitializing := false\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tfoundInitializing = true\n\t\t\tif s.Status != \"initializing\" {\n\t\t\t\tt.Fatalf(\"initial codegraph status = %q, want initializing; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundInitializing {\n\t\tt.Fatalf(\"codegraph missing before reconnect: %+v\", view.Servers)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); !ok {\n\t\tt.Fatal(\"test setup expected stale codegraph connect placeholder\")\n\t}\n\n\tif err := app.ReconnectMCPServer(\"codegraph\"); err == nil || !strings.Contains(err.Error(), \"command is required\") {\n\t\tt.Fatalf(\"ReconnectMCPServer error = %v, want missing command\", err)\n\t}\n\tif _, ok := reg.Get(\"mcp__codegraph__connect\"); ok {\n\t\tt.Fatalf(\"stale codegraph placeholder still registered after reconnect failure; names=%v\", reg.Names())\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"codegraph\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want codegraph failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\n\tview = app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"codegraph\" {\n\t\t\tif s.Status != \"failed\" || s.Error == \"\" {\n\t\t\t\tt.Fatalf(\"codegraph after failed reconnect = %+v, want failed with error\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"codegraph missing after reconnect: %+v\", view.Servers)\n}\n\nfunc TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"lazy\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer func() {\n\t\tif c := app.activeCtrl(); c != nil {\n\t\t\tc.Close()\n\t\t}\n\t}()\n\n\tif err := app.SetMCPServerTier(\"broken\", \"background\"); err != nil {\n\t\tt.Fatalf(\"SetMCPServerTier legacy binding: %v\", err)\n\t}\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got := cfg.Plugins[0].Tier; got != \"\" {\n\t\tt.Fatalf(\"saved tier = %q, want migrated empty\", got)\n\t}\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tuserPlugin, ok := findPluginEntry(userCfg.Plugins, \"broken\")\n\tif !ok {\n\t\tt.Fatalf(\"broken should be migrated to user config: %+v\", userCfg.Plugins)\n\t}\n\tif userPlugin.Tier != \"\" {\n\t\tt.Fatalf(\"user plugin tier = %q, want migrated empty\", userPlugin.Tier)\n\t}\n\tprojectCfg := config.LoadForEdit(filepath.Join(dir, \"reasonix.toml\"))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, \"broken\"); ok {\n\t\tt.Fatalf(\"project plugin should be removed after desktop migration: %+v\", projectCfg.Plugins)\n\t}\n\tif !mcpFailed(app.activeCtrl(), \"broken\") {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want broken failure recorded\", app.activeCtrl().Host().Failures())\n\t}\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want background so radio selection does not jump back\", s.Tier)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.WriteFile(filepath.Join(dir, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"broken\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tapp.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), \"\")\n\tdefer app.activeCtrl().Close()\n\trecordMCPFailure(app.activeCtrl(), config.PluginEntry{\n\t\tName: \"broken\",\n\t\tCommand: \"reasonix-missing-mcp-binary\",\n\t\tTier: \"eager\",\n\t}, errors.New(\"connect: missing binary\"))\n\n\tview := app.Capabilities()\n\tfor _, s := range view.Servers {\n\t\tif s.Name == \"broken\" {\n\t\t\tif s.Status != \"failed\" {\n\t\t\t\tt.Fatalf(\"server status = %q, want failed; server = %+v\", s.Status, s)\n\t\t\t}\n\t\t\tif s.Tier != \"background\" {\n\t\t\t\tt.Fatalf(\"server tier = %q, want migrated background default\", s.Tier)\n\t\t\t}\n\t\t\tif !s.Configured {\n\t\t\t\tt.Fatalf(\"server configured = false, want true; server = %+v\", s)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\tt.Fatalf(\"broken MCP missing from Capabilities: %+v\", view.Servers)\n}\n\nfunc TestRunShellForTabRoutesToRequestedTab(t *testing.T) {\n\tisolateDesktopUserDirs(t)\n\n\tactiveEvents := make(chan event.Event, 16)\n\tinactiveEvents := make(chan event.Event, 16)\n\tactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})\n\tinactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})\n\tdefer activeCtrl.Close()\n\tdefer inactiveCtrl.Close()\n\n\tapp := &App{\n\t\ttabs: map[string]*WorkspaceTab{\n\t\t\t\"active\": {ID: \"active\", Scope: \"global\", Ctrl: activeCtrl, Ready: true},\n\t\t\t\"inactive\": {ID: \"inactive\", Scope: \"global\", Ctrl: inactiveCtrl, Ready: true},\n\t\t},\n\t\ttabOrder: []string{\"active\", \"inactive\"},\n\t\tactiveTabID: \"active\",\n\t}\n\n\tapp.RunShellForTab(\"inactive\", \"echo route-test\")\n\n\tsawDispatch := false\n\tdeadline := time.After(3 * time.Second)\n\tfor {\n\t\tselect {\n\t\tcase e := <-inactiveEvents:\n\t\t\tif e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, \"route-test\") {\n\t\t\t\tsawDispatch = true\n\t\t\t}\n\t\t\tif e.Kind == event.TurnDone {\n\t\t\t\tif !sawDispatch {\n\t\t\t\t\tt.Fatal(\"inactive tab finished without receiving shell dispatch\")\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase active := <-activeEvents:\n\t\t\t\t\tt.Fatalf(\"active tab received event for inactive shell: %+v\", active)\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-deadline:\n\t\t\tt.Fatal(\"timed out waiting for inactive shell turn\")\n\t\t}\n\t}\n}\n\ntype blockingRunner struct {\n\tstarted chan struct{}\n\trelease chan struct{}\n}\n\nfunc (r *blockingRunner) Run(ctx context.Context, _ string) error {\n\tclose(r.started)\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-r.release:\n\t\treturn nil\n\t}\n}\n\nfunc waitNotRunning(t *testing.T, ctrl *control.Controller) {\n\tt.Helper()\n\tdeadline := time.Now().Add(time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatal(\"controller still running\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc hasLevel(levels []string, want string) bool {\n\tfor _, level := range levels {\n\t\tif level == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasCommand(cmds []CommandInfo, name string) bool {\n\tfor _, cmd := range cmds {\n\t\tif cmd.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasDirEntry(entries []DirEntry, name string) bool {\n\tfor _, entry := range entries {\n\t\tif entry.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestSessionActionsWithoutControllerReturnError(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tif err := app.NewSession(); err == nil {\n\t\tt.Error(\"NewSession with no controller must surface an error, not silently no-op\")\n\t}\n\tif err := app.ClearSession(); err == nil {\n\t\tt.Error(\"ClearSession with no controller must surface an error\")\n\t}\n\n\tapp = &App{\n\t\ttabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", StartupErr: \"boot exploded\"}},\n\t\tactiveTabID: \"t1\",\n\t}\n\terr := app.NewSession()\n\tif err == nil || !strings.Contains(err.Error(), \"boot exploded\") {\n\t\tt.Errorf(\"error should carry the tab's startup failure, got %v\", err)\n\t}\n}\n\n// --- Prompt history scanning tests ------------------------------------------\n\nfunc identityPromptDisplay(text string) string { return text }\n\n// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format\n// {\"kind\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"hello world\"}\n{\"kind\":\"user.message\",\"text\":\"second prompt\"}\n{\"kind\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello world\" {\n\t\tt.Errorf(\"expected 'hello world', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"second prompt\" {\n\t\tt.Errorf(\"expected 'second prompt', got %q\", entries[1].Text)\n\t}\n\tif entries[0].Turn != 0 || entries[1].Turn != 1 {\n\t\tt.Errorf(\"expected turns 0,1; got %d,%d\", entries[0].Turn, entries[1].Turn)\n\t}\n\tif entries[0].SessionPath != path {\n\t\tt.Errorf(\"expected session path %q, got %q\", path, entries[0].SessionPath)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event\n// format {\"type\":\"user.message\",\"text\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"type\":\"user.message\",\"text\":\"v0 prompt\"}\n{\"type\":\"model.final\",\"content\":\"response\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"v0 prompt\" {\n\t\tt.Errorf(\"expected 'v0 prompt', got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesProviderMessage verifies that the current\n// provider.Message format {\"role\":\"user\",\"content\":\"...\"} is correctly extracted.\nfunc TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"hello from provider\"}\n{\"role\":\"assistant\",\"content\":\"response\"}\n{\"role\":\"user\",\"content\":\"another prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"hello from provider\" {\n\t\tt.Errorf(\"expected 'hello from provider', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"another prompt\" {\n\t\tt.Errorf(\"expected 'another prompt', got %q\", entries[1].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the\n// same file are extracted.\nfunc TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy prompt\"}\n{\"role\":\"user\",\"content\":\"modern prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"legacy prompt\" {\n\t\tt.Errorf(\"expected 'legacy prompt', got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"modern prompt\" {\n\t\tt.Errorf(\"expected 'modern prompt', got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\trfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"user.message\",\"text\":\"legacy timed\",\"time\":1800000000123}\n{\"role\":\"user\",\"content\":\"modern timed\",\"createdAt\":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\tif entries[0].At != 1800000000123 {\n\t\tt.Errorf(\"numeric event time = %d, want 1800000000123\", entries[0].At)\n\t}\n\tif entries[1].At != rfcTime.UnixMilli() {\n\t\tt.Errorf(\"RFC3339 event time = %d, want %d\", entries[1].At, rfcTime.UnixMilli())\n\t}\n}\n\n// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses\n// the user-visible prompt text, not the controller-expanded model input.\nfunc TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\texpanded := \"\\nSaved memory\\n\\n\\nvisible prompt\"\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":`+strconv.Quote(expanded)+`}`+\"\\n\"), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := recordSessionDisplay(dir, path, expanded, \"visible prompt\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"visible prompt\" {\n\t\tt.Errorf(\"expected visible prompt, got %q\", entries[0].Text)\n\t}\n}\n\nfunc TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"role\":\"user\",\"content\":\"Plan approved — plan mode is off\"}\n{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 entry, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"real prompt\" {\n\t\tt.Errorf(\"expected real prompt, got %q\", entries[0].Text)\n\t}\n}\n\n// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only\n// assistant/tool messages returns no entries.\nfunc TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tif err := os.WriteFile(path, []byte(`{\"kind\":\"model.final\",\"content\":\"response\"}\n{\"kind\":\"tool.result\",\"output\":\"done\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file\n// returns no entries without error.\nfunc TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"empty.jsonl\")\n\tif err := os.WriteFile(path, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans\n// multiple JSONL files and returns prompts newest-first.\nfunc TestScanPromptHistoryFromDir(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{\"t1\": {ID: \"t1\", Ctrl: nil, WorkspaceRoot: \"\"}}}\n\t_ = app\n\n\tdir := t.TempDir()\n\t// Write two session files with different mtimes (sleep to ensure ordering).\n\tif err := os.WriteFile(filepath.Join(dir, \"a.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"older prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(10 * time.Millisecond)\n\tif err := os.WriteFile(filepath.Join(dir, \"b.jsonl\"), []byte(`{\"role\":\"user\",\"content\":\"newer prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 2 {\n\t\tt.Fatalf(\"expected 2 entries, got %d\", len(entries))\n\t}\n\t// Newest-first: \"newer prompt\" should be first.\n\tif entries[0].Text != \"newer prompt\" {\n\t\tt.Errorf(\"expected 'newer prompt' first, got %q\", entries[0].Text)\n\t}\n\tif entries[1].Text != \"older prompt\" {\n\t\tt.Errorf(\"expected 'older prompt' second, got %q\", entries[1].Text)\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"early first\",\"time\":%d}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\",\"time\":%d}\n`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"late newest\",\"time\":%d}\n`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: session activity should keep each session grouped\n\t// before event timestamps are considered within that session.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"early second\", \"early first\", \"late newest\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tearly := filepath.Join(dir, \"early.jsonl\")\n\tlate := filepath.Join(dir, \"late.jsonl\")\n\n\tif err := os.WriteFile(early, []byte(`{\"role\":\"user\",\"content\":\"early first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"early second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(late, []byte(`{\"role\":\"user\",\"content\":\"late newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{\n\t\tCreatedAt: base.Add(time.Minute),\n\t\tUpdatedAt: base.Add(2 * time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Invert file mtimes: branch UpdatedAt should be the activity clock.\n\tif err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 3 {\n\t\tt.Fatalf(\"expected 3 entries, got %d\", len(entries))\n\t}\n\twant := []string{\"late newest\", \"early second\", \"early first\"}\n\tfor i, w := range want {\n\t\tif entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, entries[i].Text, w, entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tbase := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tempty := filepath.Join(dir, \"empty.jsonl\")\n\treal := filepath.Join(dir, \"real.jsonl\")\n\n\tif err := os.WriteFile(empty, nil, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(real, []byte(`{\"role\":\"user\",\"content\":\"real prompt\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base.Add(time.Hour),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{\n\t\tCreatedAt: base,\n\t\tUpdatedAt: base,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 1 || entries[0].Text != \"real prompt\" {\n\t\tt.Fatalf(\"entries = %+v, want only real prompt after skipping empty session\", entries)\n\t}\n}\n\nfunc TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tif err := os.WriteFile(current, []byte(`{\"role\":\"user\",\"content\":\"current first\"}\n{\"role\":\"assistant\",\"content\":\"ok\"}\n{\"role\":\"user\",\"content\":\"current second\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != 3 {\n\t\tt.Fatalf(\"expected current-session entries followed by cross-session fallback, got %d: %+v\", len(result.Entries), result.Entries)\n\t}\n\twant := []string{\"current second\", \"current first\", \"other newest\"}\n\tfor i, w := range want {\n\t\tif result.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"entries[%d] = %q, want %q; all=%+v\", i, result.Entries[i].Text, w, result.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {\n\tdir := t.TempDir()\n\tcurrent := filepath.Join(dir, \"current.jsonl\")\n\tother := filepath.Join(dir, \"other.jsonl\")\n\tvar lines []byte\n\tfor i := range 55 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"current %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(current, lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(other, []byte(`{\"role\":\"user\",\"content\":\"other newest\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnow := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)\n\tif err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{\n\t\tCreatedAt: now,\n\t\tUpdatedAt: now,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{\n\t\tCreatedAt: now.Add(time.Minute),\n\t\tUpdatedAt: now.Add(time.Minute),\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: \"test\"})\n\tdefer ctrl.Close()\n\tapp.setTestCtrl(ctrl, \"\")\n\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(result.Entries) != promptHistoryPageLimit {\n\t\tt.Fatalf(\"expected %d entries, got %d\", promptHistoryPageLimit, len(result.Entries))\n\t}\n\tif result.Entries[0].Text != \"current 54\" {\n\t\tt.Fatalf(\"first entry = %q, want current 54\", result.Entries[0].Text)\n\t}\n\tif result.Entries[len(result.Entries)-1].Text != \"current 5\" {\n\t\tt.Fatalf(\"last first-page entry = %q, want current 5\", result.Entries[len(result.Entries)-1].Text)\n\t}\n\tif !result.HasOlder || result.OlderCursor == \"\" {\n\t\tt.Fatalf(\"first page should expose an older cursor: %+v\", result)\n\t}\n\tfor _, entry := range result.Entries {\n\t\tif entry.Text == \"other newest\" {\n\t\t\tt.Fatalf(\"cross-session entry appeared before current-session page was exhausted: %+v\", result.Entries)\n\t\t}\n\t}\n\n\tnextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnext, err := app.ScanPromptHistory(string(nextRequest))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := []string{\"current 4\", \"current 3\", \"current 2\", \"current 1\", \"current 0\", \"other newest\"}\n\tif len(next.Entries) != len(want) {\n\t\tt.Fatalf(\"second page entries = %+v, want %d entries\", next.Entries, len(want))\n\t}\n\tfor i, w := range want {\n\t\tif next.Entries[i].Text != w {\n\t\t\tt.Fatalf(\"second page entries[%d] = %q, want %q; all=%+v\", i, next.Entries[i].Text, w, next.Entries)\n\t\t}\n\t}\n}\n\nfunc TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tvar lines []byte\n\tfor i := range 250 {\n\t\tlines = append(lines, []byte(fmt.Sprintf(`{\"role\":\"user\",\"content\":\"prompt %d\"}\n`, i))...)\n\t}\n\tif err := os.WriteFile(filepath.Join(dir, \"many.jsonl\"), lines, 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 250 {\n\t\tt.Fatalf(\"expected 250 entries, got %d\", len(entries))\n\t}\n\tif entries[0].Text != \"prompt 249\" {\n\t\tt.Errorf(\"expected newest 'prompt 249' first, got %q\", entries[0].Text)\n\t}\n}\n\n// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.\nfunc TestScanPromptHistoryFromDirEmpty(t *testing.T) {\n\tapp := &App{}\n\tdir := t.TempDir()\n\tentries, err := app.scanPromptHistoryFromDir(dir)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(entries) != 0 {\n\t\tt.Errorf(\"expected 0 entries, got %d\", len(entries))\n\t}\n}\n\n// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil\n// on cache hit (nonce matches).\nfunc TestScanPromptHistoryCacheHit(t *testing.T) {\n\tapp := &App{tabs: map[string]*WorkspaceTab{}}\n\tresult, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnonce := result.Nonce\n\tif nonce == \"\" {\n\t\tt.Error(\"expected a non-empty nonce on first call\")\n\t}\n\n\t// Second call with the same nonce should be a cache hit (nil entries).\n\tresult2, err := app.ScanPromptHistory(nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result2.Entries != nil {\n\t\tt.Error(\"expected nil entries on cache hit\")\n\t}\n\tif result2.Nonce != nonce {\n\t\tt.Errorf(\"expected nonce %q unchanged, got %q\", nonce, result2.Nonce)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {\n\tdirA := t.TempDir()\n\tdirB := t.TempDir()\n\tpathA := filepath.Join(dirA, \"a.jsonl\")\n\tpathB := filepath.Join(dirB, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"workspace A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"workspace B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 1 || first.Entries[0].Text != \"workspace A\" {\n\t\tt.Fatalf(\"first entries = %+v, want workspace A\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session dir changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 1 || second.Entries[0].Text != \"workspace B\" {\n\t\tt.Fatalf(\"second entries = %+v, want workspace B\", second.Entries)\n\t}\n}\n\nfunc TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {\n\tdir := t.TempDir()\n\tpathA := filepath.Join(dir, \"a.jsonl\")\n\tpathB := filepath.Join(dir, \"b.jsonl\")\n\tif err := os.WriteFile(pathA, []byte(`{\"role\":\"user\",\"content\":\"session A\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(pathB, []byte(`{\"role\":\"user\",\"content\":\"session B\"}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tapp := NewApp()\n\tctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: \"test\"})\n\tctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: \"test\"})\n\tdefer ctrlA.Close()\n\tdefer ctrlB.Close()\n\n\tapp.setTestCtrl(ctrlA, \"\")\n\tfirst, err := app.ScanPromptHistory(\"\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(first.Entries) != 2 || first.Entries[0].Text != \"session A\" || first.Entries[1].Text != \"session B\" {\n\t\tt.Fatalf(\"first entries = %+v, want session A followed by session B\", first.Entries)\n\t}\n\n\tapp.setTestCtrl(ctrlB, \"\")\n\tsecond, err := app.ScanPromptHistory(first.Nonce)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif second.Entries == nil {\n\t\tt.Fatal(\"expected rescan after session path changes, got cache hit\")\n\t}\n\tif len(second.Entries) != 2 || second.Entries[0].Text != \"session B\" || second.Entries[1].Text != \"session A\" {\n\t\tt.Fatalf(\"second entries = %+v, want session B followed by session A\", second.Entries)\n\t}\n}\n"}}, "desktop/write_mode.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785745636.7758636, "n": 6207, "h": "289581588b1cee46", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\n// --- Write Mode: Markdown Workspace ---\n\n// MarkdownFileEntry is one file in the workspace for the Write Mode browser.\ntype MarkdownFileEntry struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n\tRelPath string `json:\"relPath\"`\n\tSize int64 `json:\"size\"`\n}\n\n// MarkdownContent is the full content of a markdown file.\ntype MarkdownContent struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tContent string `json:\"content\"`\n}\n\n// ListMarkdownFiles returns all .md files under the project workspace.\nfunc (a *App) ListMarkdownFiles() []MarkdownFileEntry {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\n\tvar entries []MarkdownFileEntry\n\t_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil // skip inaccessible\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tbase := filepath.Base(path)\n\t\t\tif strings.HasPrefix(base, \".\") && path != root {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(info.Name()), \".md\") {\n\t\t\treturn nil\n\t\t}\n\t\trel, _ := filepath.Rel(root, path)\n\t\tentries = append(entries, MarkdownFileEntry{\n\t\t\tName: info.Name(),\n\t\t\tPath: path,\n\t\t\tRelPath: rel,\n\t\t\tSize: info.Size(),\n\t\t})\n\t\treturn nil\n\t})\n\treturn entries\n}\n\n// ReadMarkdownFile reads a markdown file by relative path and returns its content.\nfunc (a *App) ReadMarkdownFile(relPath string) *MarkdownContent {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\t// Safety: ensure the resolved path is still under root.\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &MarkdownContent{\n\t\tPath: relPath,\n\t\tName: filepath.Base(relPath),\n\t\tContent: string(data),\n\t}\n}\n\n// SaveMarkdownFile writes content to a markdown file by relative path.\nfunc (a *App) SaveMarkdownFile(relPath, content string) error {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\t// File doesn't exist yet — ensure it's under root.\n\t\tabs, aerr := filepath.Abs(fullPath)\n\t\tif aerr != nil {\n\t\t\treturn aerr\n\t\t}\n\t\tif !strings.HasPrefix(abs, root) {\n\t\t\treturn os.ErrPermission\n\t\t}\n\t\t// Create parent directories.\n\t\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn os.WriteFile(abs, []byte(content), 0o600)\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn os.ErrPermission\n\t}\n\treturn os.WriteFile(resolved, []byte(content), 0o600)\n}\n\n// CreateMarkdownFile creates a new .md file with optional initial content.\n// Returns the relative path on success.\nfunc (a *App) CreateMarkdownFile(relPath, content string) (string, error) {\n\tif !strings.HasSuffix(strings.ToLower(relPath), \".md\") {\n\t\trelPath += \".md\"\n\t}\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn \"\", nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn \"\", nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tabs, err := filepath.Abs(fullPath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !strings.HasPrefix(abs, root) {\n\t\treturn \"\", os.ErrPermission\n\t}\n\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := os.WriteFile(abs, []byte(content), 0o600); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn relPath, nil\n}\n\n// FIMResult is the response from a Fill-in-the-Middle completion request.\ntype FIMResult struct {\n\tText string `json:\"text\"`\n}\n\n// FIMComplete requests a Fill-in-the-Middle completion for the given file content\n// at the cursor position. prefix = content before cursor, suffix = content after.\nfunc (a *App) FIMComplete(relPath string, cursorPos int) *FIMResult {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tcontent := string(data)\n\tif cursorPos < 0 {\n\t\tcursorPos = 0\n\t}\n\tif cursorPos > len(content) {\n\t\tcursorPos = len(content)\n\t}\n\tprefix := content[:cursorPos]\n\tsuffix := content[cursorPos:]\n\n\t// Read API config from loaded user config.\n\tapiKey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tbaseURL := os.Getenv(\"DEEPSEEK_BASE_URL\")\n\tmodel := os.Getenv(\"DEEPSEEK_FIM_MODEL\")\n\tif model == \"\" {\n\t\tmodel = \"deepseek-chat\" // FIM-compatible model\n\t}\n\tif apiKey == \"\" {\n\t\treturn nil\n\t}\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.deepseek.com\"\n\t}\n\n\tcompletion, err := fimRequest(baseURL, apiKey, model, prefix, suffix)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &FIMResult{Text: completion}\n}\n\n// fimRequest calls the DeepSeek API FIM completions endpoint.\nfunc fimRequest(baseURL, apiKey, model, prefix, suffix string) (string, error) {\n\tbody := map[string]any{\n\t\t\"model\": model,\n\t\t\"prompt\": prefix,\n\t\t\"suffix\": suffix,\n\t\t\"max_tokens\": 256,\n\t\t\"temperature\": 0.0,\n\t\t\"stop\": []string{\"\\n\\n\\n\"},\n\t}\n\tb, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq, err := http.NewRequest(\"POST\", strings.TrimRight(baseURL, \"/\")+\"/v1/completions\", bytes.NewReader(b))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result struct {\n\t\tChoices []struct {\n\t\t\tText string `json:\"text\"`\n\t\t} `json:\"choices\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn \"\", err\n\t}\n\tif len(result.Choices) == 0 {\n\t\treturn \"\", nil\n\t}\n\treturn result.Choices[0].Text, nil\n}\n"}, "3c1bc90980dfd3a280f12ba588255f66b436626d": {"t": 1785750395.0505033, "n": 6207, "h": "289581588b1cee46", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\n// --- Write Mode: Markdown Workspace ---\n\n// MarkdownFileEntry is one file in the workspace for the Write Mode browser.\ntype MarkdownFileEntry struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n\tRelPath string `json:\"relPath\"`\n\tSize int64 `json:\"size\"`\n}\n\n// MarkdownContent is the full content of a markdown file.\ntype MarkdownContent struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tContent string `json:\"content\"`\n}\n\n// ListMarkdownFiles returns all .md files under the project workspace.\nfunc (a *App) ListMarkdownFiles() []MarkdownFileEntry {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\n\tvar entries []MarkdownFileEntry\n\t_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil // skip inaccessible\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tbase := filepath.Base(path)\n\t\t\tif strings.HasPrefix(base, \".\") && path != root {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(info.Name()), \".md\") {\n\t\t\treturn nil\n\t\t}\n\t\trel, _ := filepath.Rel(root, path)\n\t\tentries = append(entries, MarkdownFileEntry{\n\t\t\tName: info.Name(),\n\t\t\tPath: path,\n\t\t\tRelPath: rel,\n\t\t\tSize: info.Size(),\n\t\t})\n\t\treturn nil\n\t})\n\treturn entries\n}\n\n// ReadMarkdownFile reads a markdown file by relative path and returns its content.\nfunc (a *App) ReadMarkdownFile(relPath string) *MarkdownContent {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\t// Safety: ensure the resolved path is still under root.\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &MarkdownContent{\n\t\tPath: relPath,\n\t\tName: filepath.Base(relPath),\n\t\tContent: string(data),\n\t}\n}\n\n// SaveMarkdownFile writes content to a markdown file by relative path.\nfunc (a *App) SaveMarkdownFile(relPath, content string) error {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\t// File doesn't exist yet — ensure it's under root.\n\t\tabs, aerr := filepath.Abs(fullPath)\n\t\tif aerr != nil {\n\t\t\treturn aerr\n\t\t}\n\t\tif !strings.HasPrefix(abs, root) {\n\t\t\treturn os.ErrPermission\n\t\t}\n\t\t// Create parent directories.\n\t\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn os.WriteFile(abs, []byte(content), 0o600)\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn os.ErrPermission\n\t}\n\treturn os.WriteFile(resolved, []byte(content), 0o600)\n}\n\n// CreateMarkdownFile creates a new .md file with optional initial content.\n// Returns the relative path on success.\nfunc (a *App) CreateMarkdownFile(relPath, content string) (string, error) {\n\tif !strings.HasSuffix(strings.ToLower(relPath), \".md\") {\n\t\trelPath += \".md\"\n\t}\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn \"\", nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn \"\", nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tabs, err := filepath.Abs(fullPath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !strings.HasPrefix(abs, root) {\n\t\treturn \"\", os.ErrPermission\n\t}\n\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := os.WriteFile(abs, []byte(content), 0o600); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn relPath, nil\n}\n\n// FIMResult is the response from a Fill-in-the-Middle completion request.\ntype FIMResult struct {\n\tText string `json:\"text\"`\n}\n\n// FIMComplete requests a Fill-in-the-Middle completion for the given file content\n// at the cursor position. prefix = content before cursor, suffix = content after.\nfunc (a *App) FIMComplete(relPath string, cursorPos int) *FIMResult {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tcontent := string(data)\n\tif cursorPos < 0 {\n\t\tcursorPos = 0\n\t}\n\tif cursorPos > len(content) {\n\t\tcursorPos = len(content)\n\t}\n\tprefix := content[:cursorPos]\n\tsuffix := content[cursorPos:]\n\n\t// Read API config from loaded user config.\n\tapiKey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tbaseURL := os.Getenv(\"DEEPSEEK_BASE_URL\")\n\tmodel := os.Getenv(\"DEEPSEEK_FIM_MODEL\")\n\tif model == \"\" {\n\t\tmodel = \"deepseek-chat\" // FIM-compatible model\n\t}\n\tif apiKey == \"\" {\n\t\treturn nil\n\t}\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.deepseek.com\"\n\t}\n\n\tcompletion, err := fimRequest(baseURL, apiKey, model, prefix, suffix)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &FIMResult{Text: completion}\n}\n\n// fimRequest calls the DeepSeek API FIM completions endpoint.\nfunc fimRequest(baseURL, apiKey, model, prefix, suffix string) (string, error) {\n\tbody := map[string]any{\n\t\t\"model\": model,\n\t\t\"prompt\": prefix,\n\t\t\"suffix\": suffix,\n\t\t\"max_tokens\": 256,\n\t\t\"temperature\": 0.0,\n\t\t\"stop\": []string{\"\\n\\n\\n\"},\n\t}\n\tb, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq, err := http.NewRequest(\"POST\", strings.TrimRight(baseURL, \"/\")+\"/v1/completions\", bytes.NewReader(b))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result struct {\n\t\tChoices []struct {\n\t\t\tText string `json:\"text\"`\n\t\t} `json:\"choices\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn \"\", err\n\t}\n\tif len(result.Choices) == 0 {\n\t\treturn \"\", nil\n\t}\n\treturn result.Choices[0].Text, nil\n}\n"}, "8a7da9a5803ae7c720ae2e135256e45e6bc168ce": {"t": 1785750396.1203654, "n": 6207, "h": "b7e1b1da9785c078", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\n// --- Write Mode: Markdown Workspace ---\n\n// MarkdownFileEntry is one file in the workspace for the Write Mode browser.\ntype MarkdownFileEntry struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n\tRelPath string `json:\"relPath\"`\n\tSize int64 `json:\"size\"`\n}\n\n// MarkdownContent is the full content of a markdown file.\ntype MarkdownContent struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tContent string `json:\"content\"`\n}\n\n// ListMarkdownFiles returns all .md files under the project workspace.\nfunc (a *App) ListMarkdownFiles() []MarkdownFileEntry {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\n\tvar entries []MarkdownFileEntry\n\t_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil // skip inaccessible\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tbase := filepath.Base(path)\n\t\t\tif strings.HasPrefix(base, \".\") && path != root {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(info.Name()), \".md\") {\n\t\t\treturn nil\n\t\t}\n\t\trel, _ := filepath.Rel(root, path)\n\t\tentries = append(entries, MarkdownFileEntry{\n\t\t\tName: info.Name(),\n\t\t\tPath: path,\n\t\t\tRelPath: rel,\n\t\t\tSize: info.Size(),\n\t\t})\n\t\treturn nil\n\t})\n\treturn entries\n}\n\n// ReadMarkdownFile reads a markdown file by relative path and returns its content.\nfunc (a *App) ReadMarkdownFile(relPath string) *MarkdownContent {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\t// Safety: ensure the resolved path is still under root.\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &MarkdownContent{\n\t\tPath: relPath,\n\t\tName: filepath.Base(relPath),\n\t\tContent: string(data),\n\t}\n}\n\n// SaveMarkdownFile writes content to a markdown file by relative path.\nfunc (a *App) SaveMarkdownFile(relPath, content string) error {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\t// File doesn't exist yet — ensure it's under root.\n\t\tabs, aerr := filepath.Abs(fullPath)\n\t\tif aerr != nil {\n\t\t\treturn aerr\n\t\t}\n\t\tif !strings.HasPrefix(abs, root) {\n\t\t\treturn os.ErrPermission\n\t\t}\n\t\t// Create parent directories.\n\t\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn os.WriteFile(abs, []byte(content), 0o600)\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn os.ErrPermission\n\t}\n\treturn os.WriteFile(resolved, []byte(content), 0o644)\n}\n\n// CreateMarkdownFile creates a new .md file with optional initial content.\n// Returns the relative path on success.\nfunc (a *App) CreateMarkdownFile(relPath, content string) (string, error) {\n\tif !strings.HasSuffix(strings.ToLower(relPath), \".md\") {\n\t\trelPath += \".md\"\n\t}\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn \"\", nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn \"\", nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tabs, err := filepath.Abs(fullPath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !strings.HasPrefix(abs, root) {\n\t\treturn \"\", os.ErrPermission\n\t}\n\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := os.WriteFile(abs, []byte(content), 0o600); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn relPath, nil\n}\n\n// FIMResult is the response from a Fill-in-the-Middle completion request.\ntype FIMResult struct {\n\tText string `json:\"text\"`\n}\n\n// FIMComplete requests a Fill-in-the-Middle completion for the given file content\n// at the cursor position. prefix = content before cursor, suffix = content after.\nfunc (a *App) FIMComplete(relPath string, cursorPos int) *FIMResult {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tcontent := string(data)\n\tif cursorPos < 0 {\n\t\tcursorPos = 0\n\t}\n\tif cursorPos > len(content) {\n\t\tcursorPos = len(content)\n\t}\n\tprefix := content[:cursorPos]\n\tsuffix := content[cursorPos:]\n\n\t// Read API config from loaded user config.\n\tapiKey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tbaseURL := os.Getenv(\"DEEPSEEK_BASE_URL\")\n\tmodel := os.Getenv(\"DEEPSEEK_FIM_MODEL\")\n\tif model == \"\" {\n\t\tmodel = \"deepseek-chat\" // FIM-compatible model\n\t}\n\tif apiKey == \"\" {\n\t\treturn nil\n\t}\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.deepseek.com\"\n\t}\n\n\tcompletion, err := fimRequest(baseURL, apiKey, model, prefix, suffix)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &FIMResult{Text: completion}\n}\n\n// fimRequest calls the DeepSeek API FIM completions endpoint.\nfunc fimRequest(baseURL, apiKey, model, prefix, suffix string) (string, error) {\n\tbody := map[string]any{\n\t\t\"model\": model,\n\t\t\"prompt\": prefix,\n\t\t\"suffix\": suffix,\n\t\t\"max_tokens\": 256,\n\t\t\"temperature\": 0.0,\n\t\t\"stop\": []string{\"\\n\\n\\n\"},\n\t}\n\tb, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq, err := http.NewRequest(\"POST\", strings.TrimRight(baseURL, \"/\")+\"/v1/completions\", bytes.NewReader(b))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result struct {\n\t\tChoices []struct {\n\t\t\tText string `json:\"text\"`\n\t\t} `json:\"choices\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn \"\", err\n\t}\n\tif len(result.Choices) == 0 {\n\t\treturn \"\", nil\n\t}\n\treturn result.Choices[0].Text, nil\n}\n"}, "4d45eb2154cacb7da7cc254ff5c848ddebaddac5": {"t": 1785750397.4259558, "n": 6207, "h": "a69a485feb66fb3b", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\n// --- Write Mode: Markdown Workspace ---\n\n// MarkdownFileEntry is one file in the workspace for the Write Mode browser.\ntype MarkdownFileEntry struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n\tRelPath string `json:\"relPath\"`\n\tSize int64 `json:\"size\"`\n}\n\n// MarkdownContent is the full content of a markdown file.\ntype MarkdownContent struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tContent string `json:\"content\"`\n}\n\n// ListMarkdownFiles returns all .md files under the project workspace.\nfunc (a *App) ListMarkdownFiles() []MarkdownFileEntry {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\n\tvar entries []MarkdownFileEntry\n\t_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil // skip inaccessible\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tbase := filepath.Base(path)\n\t\t\tif strings.HasPrefix(base, \".\") && path != root {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(info.Name()), \".md\") {\n\t\t\treturn nil\n\t\t}\n\t\trel, _ := filepath.Rel(root, path)\n\t\tentries = append(entries, MarkdownFileEntry{\n\t\t\tName: info.Name(),\n\t\t\tPath: path,\n\t\t\tRelPath: rel,\n\t\t\tSize: info.Size(),\n\t\t})\n\t\treturn nil\n\t})\n\treturn entries\n}\n\n// ReadMarkdownFile reads a markdown file by relative path and returns its content.\nfunc (a *App) ReadMarkdownFile(relPath string) *MarkdownContent {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\t// Safety: ensure the resolved path is still under root.\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &MarkdownContent{\n\t\tPath: relPath,\n\t\tName: filepath.Base(relPath),\n\t\tContent: string(data),\n\t}\n}\n\n// SaveMarkdownFile writes content to a markdown file by relative path.\nfunc (a *App) SaveMarkdownFile(relPath, content string) error {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\t// File doesn't exist yet — ensure it's under root.\n\t\tabs, aerr := filepath.Abs(fullPath)\n\t\tif aerr != nil {\n\t\t\treturn aerr\n\t\t}\n\t\tif !strings.HasPrefix(abs, root) {\n\t\t\treturn os.ErrPermission\n\t\t}\n\t\t// Create parent directories.\n\t\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn os.WriteFile(abs, []byte(content), 0o644)\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn os.ErrPermission\n\t}\n\treturn os.WriteFile(resolved, []byte(content), 0o644)\n}\n\n// CreateMarkdownFile creates a new .md file with optional initial content.\n// Returns the relative path on success.\nfunc (a *App) CreateMarkdownFile(relPath, content string) (string, error) {\n\tif !strings.HasSuffix(strings.ToLower(relPath), \".md\") {\n\t\trelPath += \".md\"\n\t}\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn \"\", nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn \"\", nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tabs, err := filepath.Abs(fullPath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !strings.HasPrefix(abs, root) {\n\t\treturn \"\", os.ErrPermission\n\t}\n\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := os.WriteFile(abs, []byte(content), 0o644); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn relPath, nil\n}\n\n// FIMResult is the response from a Fill-in-the-Middle completion request.\ntype FIMResult struct {\n\tText string `json:\"text\"`\n}\n\n// FIMComplete requests a Fill-in-the-Middle completion for the given file content\n// at the cursor position. prefix = content before cursor, suffix = content after.\nfunc (a *App) FIMComplete(relPath string, cursorPos int) *FIMResult {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tcontent := string(data)\n\tif cursorPos < 0 {\n\t\tcursorPos = 0\n\t}\n\tif cursorPos > len(content) {\n\t\tcursorPos = len(content)\n\t}\n\tprefix := content[:cursorPos]\n\tsuffix := content[cursorPos:]\n\n\t// Read API config from loaded user config.\n\tapiKey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tbaseURL := os.Getenv(\"DEEPSEEK_BASE_URL\")\n\tmodel := os.Getenv(\"DEEPSEEK_FIM_MODEL\")\n\tif model == \"\" {\n\t\tmodel = \"deepseek-chat\" // FIM-compatible model\n\t}\n\tif apiKey == \"\" {\n\t\treturn nil\n\t}\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.deepseek.com\"\n\t}\n\n\tcompletion, err := fimRequest(baseURL, apiKey, model, prefix, suffix)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &FIMResult{Text: completion}\n}\n\n// fimRequest calls the DeepSeek API FIM completions endpoint.\nfunc fimRequest(baseURL, apiKey, model, prefix, suffix string) (string, error) {\n\tbody := map[string]any{\n\t\t\"model\": model,\n\t\t\"prompt\": prefix,\n\t\t\"suffix\": suffix,\n\t\t\"max_tokens\": 256,\n\t\t\"temperature\": 0.0,\n\t\t\"stop\": []string{\"\\n\\n\\n\"},\n\t}\n\tb, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq, err := http.NewRequest(\"POST\", strings.TrimRight(baseURL, \"/\")+\"/v1/completions\", bytes.NewReader(b))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result struct {\n\t\tChoices []struct {\n\t\t\tText string `json:\"text\"`\n\t\t} `json:\"choices\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn \"\", err\n\t}\n\tif len(result.Choices) == 0 {\n\t\treturn \"\", nil\n\t}\n\treturn result.Choices[0].Text, nil\n}\n"}, "9b7837430627165b824d664c472921d87d06ba81": {"t": 1785750398.5518672, "n": 6175, "h": "900f307867b44e7b", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// --- Write Mode: Markdown Workspace ---\n\n// MarkdownFileEntry is one file in the workspace for the Write Mode browser.\ntype MarkdownFileEntry struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n\tRelPath string `json:\"relPath\"`\n\tSize int64 `json:\"size\"`\n}\n\n// MarkdownContent is the full content of a markdown file.\ntype MarkdownContent struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tContent string `json:\"content\"`\n}\n\n// ListMarkdownFiles returns all .md files under the project workspace.\nfunc (a *App) ListMarkdownFiles() []MarkdownFileEntry {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\n\tvar entries []MarkdownFileEntry\n\t_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil // skip inaccessible\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tbase := filepath.Base(path)\n\t\t\tif strings.HasPrefix(base, \".\") && path != root {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(info.Name()), \".md\") {\n\t\t\treturn nil\n\t\t}\n\t\trel, _ := filepath.Rel(root, path)\n\t\tentries = append(entries, MarkdownFileEntry{\n\t\t\tName: info.Name(),\n\t\t\tPath: path,\n\t\t\tRelPath: rel,\n\t\t\tSize: info.Size(),\n\t\t})\n\t\treturn nil\n\t})\n\treturn entries\n}\n\n// ReadMarkdownFile reads a markdown file by relative path and returns its content.\nfunc (a *App) ReadMarkdownFile(relPath string) *MarkdownContent {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\t// Safety: ensure the resolved path is still under root.\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &MarkdownContent{\n\t\tPath: relPath,\n\t\tName: filepath.Base(relPath),\n\t\tContent: string(data),\n\t}\n}\n\n// SaveMarkdownFile writes content to a markdown file by relative path.\nfunc (a *App) SaveMarkdownFile(relPath, content string) error {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\t// File doesn't exist yet — ensure it's under root.\n\t\tabs, aerr := filepath.Abs(fullPath)\n\t\tif aerr != nil {\n\t\t\treturn aerr\n\t\t}\n\t\tif !strings.HasPrefix(abs, root) {\n\t\t\treturn os.ErrPermission\n\t\t}\n\t\t// Create parent directories.\n\t\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn os.WriteFile(abs, []byte(content), 0o644)\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn os.ErrPermission\n\t}\n\treturn os.WriteFile(resolved, []byte(content), 0o644)\n}\n\n// CreateMarkdownFile creates a new .md file with optional initial content.\n// Returns the relative path on success.\nfunc (a *App) CreateMarkdownFile(relPath, content string) (string, error) {\n\tif !strings.HasSuffix(strings.ToLower(relPath), \".md\") {\n\t\trelPath += \".md\"\n\t}\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn \"\", nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn \"\", nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tabs, err := filepath.Abs(fullPath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !strings.HasPrefix(abs, root) {\n\t\treturn \"\", os.ErrPermission\n\t}\n\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := os.WriteFile(abs, []byte(content), 0o644); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn relPath, nil\n}\n\n// FIMResult is the response from a Fill-in-the-Middle completion request.\ntype FIMResult struct {\n\tText string `json:\"text\"`\n}\n\n// FIMComplete requests a Fill-in-the-Middle completion for the given file content\n// at the cursor position. prefix = content before cursor, suffix = content after.\nfunc (a *App) FIMComplete(relPath string, cursorPos int) *FIMResult {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tcontent := string(data)\n\tif cursorPos < 0 {\n\t\tcursorPos = 0\n\t}\n\tif cursorPos > len(content) {\n\t\tcursorPos = len(content)\n\t}\n\tprefix := content[:cursorPos]\n\tsuffix := content[cursorPos:]\n\n\t// Read API config from loaded user config.\n\tapiKey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tbaseURL := os.Getenv(\"DEEPSEEK_BASE_URL\")\n\tmodel := os.Getenv(\"DEEPSEEK_FIM_MODEL\")\n\tif model == \"\" {\n\t\tmodel = \"deepseek-chat\" // FIM-compatible model\n\t}\n\tif apiKey == \"\" {\n\t\treturn nil\n\t}\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.deepseek.com\"\n\t}\n\n\tcompletion, err := fimRequest(baseURL, apiKey, model, prefix, suffix)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &FIMResult{Text: completion}\n}\n\n// fimRequest calls the DeepSeek API FIM completions endpoint.\nfunc fimRequest(baseURL, apiKey, model, prefix, suffix string) (string, error) {\n\tbody := map[string]any{\n\t\t\"model\": model,\n\t\t\"prompt\": prefix,\n\t\t\"suffix\": suffix,\n\t\t\"max_tokens\": 256,\n\t\t\"temperature\": 0.0,\n\t\t\"stop\": []string{\"\\n\\n\\n\"},\n\t}\n\tb, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq, err := http.NewRequest(\"POST\", strings.TrimRight(baseURL, \"/\")+\"/v1/completions\", bytes.NewReader(b))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tvar result struct {\n\t\tChoices []struct {\n\t\t\tText string `json:\"text\"`\n\t\t} `json:\"choices\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {\n\t\treturn \"\", err\n\t}\n\tif len(result.Choices) == 0 {\n\t\treturn \"\", nil\n\t}\n\treturn result.Choices[0].Text, nil\n}\n"}, "a64e807343b72c11741a0d7c3a585fcfacdd63e9": {"t": 1785750400.3361387, "n": 3729, "h": "b80f971a15b38d95", "c": "package main\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// --- Write Mode: Markdown Workspace ---\n\n// MarkdownFileEntry is one file in the workspace for the Write Mode browser.\ntype MarkdownFileEntry struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n\tRelPath string `json:\"relPath\"`\n\tSize int64 `json:\"size\"`\n}\n\n// MarkdownContent is the full content of a markdown file.\ntype MarkdownContent struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tContent string `json:\"content\"`\n}\n\n// ListMarkdownFiles returns all .md files under the project workspace.\nfunc (a *App) ListMarkdownFiles() []MarkdownFileEntry {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\n\tvar entries []MarkdownFileEntry\n\t_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil // skip inaccessible\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tbase := filepath.Base(path)\n\t\t\tif strings.HasPrefix(base, \".\") && path != root {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tif !strings.HasSuffix(strings.ToLower(info.Name()), \".md\") {\n\t\t\treturn nil\n\t\t}\n\t\trel, _ := filepath.Rel(root, path)\n\t\tentries = append(entries, MarkdownFileEntry{\n\t\t\tName: info.Name(),\n\t\t\tPath: path,\n\t\t\tRelPath: rel,\n\t\t\tSize: info.Size(),\n\t\t})\n\t\treturn nil\n\t})\n\treturn entries\n}\n\n// ReadMarkdownFile reads a markdown file by relative path and returns its content.\nfunc (a *App) ReadMarkdownFile(relPath string) *MarkdownContent {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\t// Safety: ensure the resolved path is still under root.\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn nil\n\t}\n\tdata, err := os.ReadFile(resolved)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &MarkdownContent{\n\t\tPath: relPath,\n\t\tName: filepath.Base(relPath),\n\t\tContent: string(data),\n\t}\n}\n\n// SaveMarkdownFile writes content to a markdown file by relative path.\nfunc (a *App) SaveMarkdownFile(relPath, content string) error {\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tresolved, err := filepath.EvalSymlinks(fullPath)\n\tif err != nil {\n\t\t// File doesn't exist yet — ensure it's under root.\n\t\tabs, aerr := filepath.Abs(fullPath)\n\t\tif aerr != nil {\n\t\t\treturn aerr\n\t\t}\n\t\tif !strings.HasPrefix(abs, root) {\n\t\t\treturn os.ErrPermission\n\t\t}\n\t\t// Create parent directories.\n\t\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn os.WriteFile(abs, []byte(content), 0o644)\n\t}\n\tif !strings.HasPrefix(resolved, root) {\n\t\treturn os.ErrPermission\n\t}\n\treturn os.WriteFile(resolved, []byte(content), 0o644)\n}\n\n// CreateMarkdownFile creates a new .md file with optional initial content.\n// Returns the relative path on success.\nfunc (a *App) CreateMarkdownFile(relPath, content string) (string, error) {\n\tif !strings.HasSuffix(strings.ToLower(relPath), \".md\") {\n\t\trelPath += \".md\"\n\t}\n\tctrl := a.ctrlForTab(\"\")\n\tif ctrl == nil {\n\t\treturn \"\", nil\n\t}\n\troot := ctrl.WorkspaceRoot()\n\tif root == \"\" {\n\t\treturn \"\", nil\n\t}\n\tfullPath := filepath.Join(root, relPath)\n\tabs, err := filepath.Abs(fullPath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !strings.HasPrefix(abs, root) {\n\t\treturn \"\", os.ErrPermission\n\t}\n\tif err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := os.WriteFile(abs, []byte(content), 0o644); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn relPath, nil\n}\n"}}, "internal/i18n/messages_en.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785745647.0019386, "n": 22569, "h": "4b9c0a494ecfe02f", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusCancellingFmt: \"%s stopping… (%ds · Ctrl+C exits)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdLearn: \"suggest skills from detected patterns\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdReloadCmd: \"reload custom commands\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] serve over HTTP+SSE (with optional auth)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "4fe39d3092146290b1c60b2a77f869909f77f74e": {"t": 1785750391.4385328, "n": 22569, "h": "4b9c0a494ecfe02f", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusCancellingFmt: \"%s stopping… (%ds · Ctrl+C exits)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdLearn: \"suggest skills from detected patterns\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdReloadCmd: \"reload custom commands\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] serve over HTTP+SSE (with optional auth)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "535993a271e2d97864cbf2406ffb0f71fb317d80": {"t": 1785750392.6564674, "n": 22457, "h": "f0a54004b0c28c93", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusCancellingFmt: \"%s stopping… (%ds · Ctrl+C exits)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdReloadCmd: \"reload custom commands\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] serve over HTTP+SSE (with optional auth)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "42734604235b85d3939ac9d8e20f9ed0a312fda9": {"t": 1785750393.8936846, "n": 22508, "h": "226093c31b0dd260", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusCancellingFmt: \"%s stopping… (%ds · Ctrl+C exits)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdLearn: \"suggest skills from detected patterns\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdReloadCmd: \"reload custom commands\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "4da0a184c390f5b240cf1326e5327ca1e8711548": {"t": 1785750395.103571, "n": 22396, "h": "cf81debe168aa5f1", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusCancellingFmt: \"%s stopping… (%ds · Ctrl+C exits)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdReloadCmd: \"reload custom commands\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "66a016261b533bac73c1afc9eaeef1792b9ebe36": {"t": 1785750396.3436816, "n": 22464, "h": "5d107b3394a0062e", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusCancellingFmt: \"%s stopping… (%ds · Ctrl+C exits)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdLearn: \"suggest skills from detected patterns\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "53d515bb366882030894a603e87f4909abd27ce2": {"t": 1785750397.589974, "n": 22352, "h": "93a9b24a6832fd77", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusCancellingFmt: \"%s stopping… (%ds · Ctrl+C exits)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "5dccfd123f5183b21df1f660e6a44d5c76580392": {"t": 1785750398.816921, "n": 22397, "h": "ded22c66ab1e69aa", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdLearn: \"suggest skills from detected patterns\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "4c18de03ce6cb858efc8c4729f6e68da2e54d006": {"t": 1785750400.0427043, "n": 22338, "h": "944a274dede7013a", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "6f39a76858cb3fca2d293fa96c82fefc478dd479": {"t": 1785750401.3227673, "n": 22285, "h": "b70fc6a715847af8", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] [--yolo] [--dir PATH] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix init show how to generate project memory (AGENTS.md)\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release (also: reasonix update)\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "5bb79df42731103e7d147759a4b1be4908201bd2": {"t": 1785750402.5534322, "n": 22090, "h": "f08d4431541e5ef0", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "74d0ab6d937f30ff6c6429e1880447326186f017": {"t": 1785750403.8152962, "n": 22037, "h": "e13da8e735d11515", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix\n reasonix --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "ef4c7aaed5159c2f709619e0c34a26d4cb6f1da8": {"t": 1785750405.0914102, "n": 22115, "h": "02bd332038a00e55", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "5946f6c9834ef96d30c907ee9c2ddc9d5c6bcfb2": {"t": 1785750406.37841, "n": 22062, "h": "7954401aec22c40b", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /migrate · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdMigrate: \"retry legacy data migration\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "bb0769b08f346941165f392f9697560f30681aed": {"t": 1785750408.1603665, "n": 22002, "h": "dd96bd5e11036e6c", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "8d3e77c5ca893977b76fb6c4b344a983c1a7845b": {"t": 1785750409.9400206, "n": 22062, "h": "f9331948f2e30f61", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "f6d8cce7eb7294a4a9e4a5c6492cb0941112e545": {"t": 1785750411.1749876, "n": 22009, "h": "90ba5220b1734eca", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdReasonLang: \"set visible reasoning language\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix config reasoning-language [auto|zh|en] configure visible reasoning language\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "9b7837430627165b824d664c472921d87d06ba81": {"t": 1785750412.402068, "n": 21916, "h": "db37387510920885", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdWrite: \"open a markdown file in $EDITOR\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "92854c6a00c914bd735993484d12bc1e189fe57f": {"t": 1785750415.0590494, "n": 21863, "h": "8179699786abd296", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"archive a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemorySaved: \"saved memories\",\n\tListMemoryArchived: \"archived memories\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (archive with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot and archived memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "c6fa8aaa1a8011a5506bd63c33c9f9a4b2ae5afe": {"t": 1785750416.280703, "n": 21765, "h": "9ca6813cdd8dbe25", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\n\tRenameUsage: \"usage: /rename or /rename \",\n\tRenameNoSession: \"no active session to rename\",\n\tRenameDoneFmt: \"session renamed to %q\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdRename: \"rename a session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "b977d87a3f7fb7de856e3607d4b2c680e73dc381": {"t": 1785750417.5002837, "n": 21565, "h": "c2b98d8260576629", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\tDiffFoldEnabledFmt: \"diff folded to %d lines (/diff-fold to expand)\",\n\tDiffFoldDisabled: \"diff expanded — showing all lines (/diff-fold to fold)\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdDiffFold: \"toggle diff fold/expand\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "19f6bcdcf6021c4110b8990c60838b38a83f436a": {"t": 1785750419.7189388, "n": 21352, "h": "689661a03df781c6", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\t// self-update\n\tUpgradeChecking: \"Checking for updates…\",\n\tUpgradeDevBuild: \"dev builds cannot self-update\",\n\tUpgradeFetchFailed: \"failed to check for updates: %v\",\n\tUpgradeInvalidVersion: \"remote version is not valid semver\",\n\tUpgradeAlreadyLatest: \"Already on the latest version.\",\n\tUpgradeForcing: \"Reinstalling the same version…\",\n\tUpgradeAvailableFmt: \"Current: %s → Latest: %s\",\n\tUpgradeNoAssetFmt: \"no binary found for %s\",\n\tUpgradeDownloadingFmt: \"Downloading %s (%s)…\",\n\tUpgradeDownloadFailed: \"download failed: %v\",\n\tUpgradeVerifying: \"Verifying checksum…\",\n\tUpgradeChecksumFailed: \"could not fetch checksum file: %v\",\n\tUpgradeChecksumMismatchFmt: \"SHA256 mismatch: got %s, want %s\",\n\tUpgradeChecksumNotFoundFmt: \"%s not found in SHA256SUMS\",\n\tUpgradeExtractFailed: \"failed to extract binary: %v\",\n\tUpgradeApplying: \"Replacing binary…\",\n\tUpgradeApplyFailed: \"failed to apply update: %v\",\n\tUpgradeSuccessFmt: \"Updated %s → %s\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix upgrade [--check] [--force] self-update to the latest release\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "6edcf5adc3d3cba5263e44c79f3b577a789b65ea": {"t": 1785750420.85446, "n": 20194, "h": "087164162145e12f", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing or unset. Add it to .env or run `reasonix setup`.\",\n\tProviderErrAuthRejected: \"Authentication failed (HTTP 401): the server rejected your API key. It may be wrong or expired, or the provider hit a transient auth/quota issue — retried with backoff and still failed. Try again shortly, or check the key in .env / run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "53eb72c9e5d1cd3faeff23ee31c5eba708146d2b": {"t": 1785750421.9937072, "n": 19921, "h": "fb940429fbc6a747", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing, wrong, or expired. Check the key in .env or run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel; / to search)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q; / to search)\",\n\n\tSelectMoreAboveFmt: \" ↑ %d more above\",\n\tSelectMoreBelowFmt: \" ↓ %d more below\",\n\tSelectSearchHint: \"/ to search · type to filter · Esc cancel search\",\n\n\tCmdProvider: \"switch provider\",\n\tProviderListHeader: \"providers (/provider to switch)\",\n\tProviderAlreadyOnFmt: \"already using provider %s\",\n\tProviderUnknownFmt: \"unknown provider %q\",\n\tProviderPickLabel: \"Select a model from %s\",\n\tProviderNoModelsFmt: \"provider %s has no configured models\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "040126d224b297e561fecea3c330bcfd02b8aecc": {"t": 1785750423.5452688, "n": 19419, "h": "601dd316522b9998", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan · ctrl+y yolo\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing, wrong, or expired. Check the key in .env or run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q)\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "f45708c906ceb1893957dceb822da4541679a0b6": {"t": 1785750424.6956246, "n": 19405, "h": "ebb6061ae11065d8", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"new session started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashClearPrompt: \"Clear current context without saving?\",\n\tSlashClearDone: \"current context cleared\",\n\tSlashClearFailed: \"could not clear current context\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new · /clear · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start new session; save transcript\",\n\tCmdClear: \"discard current context\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing, wrong, or expired. Check the key in .env or run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q)\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "655223c1cc37223bb5cb6051c130e8cbe8994e37": {"t": 1785750425.817367, "n": 19198, "h": "97c4cc13abb9de80", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn/Ctrl+Home/End scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"fresh context started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new (/clear) · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start fresh context; save transcript\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing, wrong, or expired. Check the key in .env or run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q)\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "28bedc6b7ffe167c6325863a2d3ff0034f7bb7d3": {"t": 1785750426.952531, "n": 19184, "h": "4882fadd009db3d6", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"fresh context started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new (/clear) · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start fresh context; save transcript\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing, wrong, or expired. Check the key in .env or run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q)\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix bot start|doctor|weixin-login multi-channel IM bot gateway\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "c80ba8ad766b41cd226104afdfcc796559afd14e": {"t": 1785750428.0636308, "n": 19099, "h": "04c17ab22dc7a124", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"tool approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab toggles plan\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"fresh context started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new (/clear) · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /goal · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start fresh context; save transcript\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdGoal: \"set or clear the active goal\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tGoalEmpty: \"goal: none — set one with /goal \",\n\tGoalCurrentFmt: \"goal: %s\",\n\tGoalSetFmt: \"goal set → %s\",\n\tGoalCleared: \"goal cleared\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing, wrong, or expired. Check the key in .env or run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q)\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "f311a475d4212ef8bc3459a8173356bb46a0808e": {"t": 1785750429.1773787, "n": 18840, "h": "d1829443931d1224", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab to cycle\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow scope this session · 3/4 prefix or save when offered · n/Esc deny · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n%s\",\n\tToolApprovalChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tBashPrefixChoices: \"1. Allow once\\n2. Allow %s for this session\\n3. Always allow %s (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tPermissionSavedFmt: \"permission saved to %s: %s\",\n\tPermissionAlreadyAllowedFmt: \"permission already covered in %s: %s\",\n\tPermissionSaveFailedFmt: \"permission save failed for %s: %v\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"fresh context started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new (/clear) · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start fresh context; save transcript\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing, wrong, or expired. Check the key in .env or run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q)\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}, "58f444a6926bf130ec343efe89786202ec52275a": {"t": 1785750430.2952542, "n": 18320, "h": "9706907234e55d27", "c": "package i18n\n\n// English is the baseline catalogue. The drift-guard test reflects over its\n// fields, so every other catalogue must populate the same set.\nvar English = Messages{\n\tSubtitle: \"config + plugin driven coding agent\",\n\tWelcomeTitleFmt: \"Welcome to %s\",\n\tNoConfigYet: \"No configuration found yet — let's set it up.\",\n\tStartingChatFmt: \"Starting %s…\",\n\tSetKeyHint: \"Set your API key, then run `reasonix chat`.\",\n\tConfigLabel: \"config\",\n\tModelsLabel: \"models\",\n\tConfigNotFound: \"not found — using built-in defaults\",\n\tConfigErrorFmt: \"%s — error: %v\",\n\tNoKey: \"no key\",\n\tReady: \"ready\",\n\tGetStarted: \"Get started\",\n\tStepScaffold: \"scaffold reasonix.toml\",\n\tStepSetKey: \"set API key\",\n\n\tInitHint: \"Project memory (AGENTS.md) is generated in-session: run `reasonix chat`, then `/init` — the model analyzes the codebase and writes it. For configuration, use `reasonix setup`.\",\n\tStepSetKeyHint: \"run `reasonix setup`, or export DEEPSEEK_API_KEY=…\",\n\tStepChatDesc: \"interactive session\",\n\tStepRunDesc: \"one-shot task\",\n\tHelpFooter: \"reasonix help · all commands\",\n\n\tChatTip: \"Context is kept across turns. Type 'exit' or Ctrl-D to quit.\",\n\tTurnCancelled: \"cancelled — back to prompt\",\n\tNoSessionToResume: \"no saved session to resume — start a new one with `reasonix chat`\",\n\tResumeRequiresTTY: \"--resume needs an interactive terminal; pass --continue for the most recent session\",\n\tPickSessionLabel: \"Resume which session?\",\n\n\tResumeListHeader: \"sessions (/resume to switch)\",\n\tResumeBusy: \"finish or cancel the current turn before resuming\",\n\tResumeBadIndexFmt: \"pick a session 1–%d (run /resume to list)\",\n\tResumeAlreadyActive: \"already in that session\",\n\tResumedTitle: \"resumed session\",\n\tResumePickTitle: \"Resume a saved session\",\n\tResumePickHint: \"↑/↓ move · Enter resume · Esc cancel\",\n\n\tChatThinking: \"thinking…\",\n\tChatThoughtForFmt: \"thought for %ds\",\n\tChatStatusThinkingFmt: \"%s thinking… (%ds · Esc cancels)\",\n\tChatToolWorkingFmt: \"%s working · %ds\",\n\tChatStatusRetryingFmt: \"%s retrying (%d/%d)… (Esc cancels)\",\n\tChatStatusIdle: \"ready\",\n\tChatStatusYoloIdle: \"approvals skipped\",\n\tChatStatusCycleHint: \"shift+tab to cycle\",\n\tChatStatusCacheNowFmt: \"turn hit %s\",\n\tChatStatusCacheAvgFmt: \"avg %s\",\n\tChatStatusPlanApproval: \"Enter/y approves & executes · n/Esc keeps planning · PgUp/PgDn scrolls\",\n\tPlanApprovalPrompt: \"Plan ready above — Enter/y to approve & execute, n/Esc to keep planning\",\n\tChatStatusToolApproval: \"1 approve once · 2 allow this session · 3 always allow (save) · 4 deny · y/a/p/n also work · Ctrl-C cancels turn\",\n\tAskTypeSomething: \"Type something else\",\n\tAskTypingHint: \"type below, Enter to confirm\",\n\tAskChatInstead: \"None — just chat\",\n\tChatStatusQuestion: \"↑/↓ move · number to pick · space multi · Enter confirm · ←/→ switch · Esc cancel\",\n\tStatusResumePicker: \"↑/↓ move · Enter resume · Esc cancel\",\n\tAskSubmitTitle: \"Submit answers\",\n\tAskUnanswered: \"(unanswered)\",\n\tAskSubmitHint: \"Enter submits · ← returns to edit\",\n\tToolApprovalPromptFmt: \"Permission required\\n\\nWill call tool %s%s.\\n%s\\n1. Allow once\\n2. Allow similar calls this session\\n3. Always allow (save to config)\\n4. Deny\\nChoose [1/2/3/4] (y/a/p/n also work)\",\n\tToolApprovalSourceFmt: \"Source: %s\",\n\tToolApprovalBuiltIn: \"built-in tool\",\n\tToolApprovalImageUse: \"It will read provided image input for image understanding.\",\n\tDiffFoldedFmt: \"… +%d more lines\",\n\n\tOutputStyleNone: \"no output styles available\",\n\tOutputStyleHeader: \"output styles:\",\n\tOutputStyleHint: \"set agent.output_style in reasonix.toml to apply one (takes effect next session)\",\n\tThemeHeader: \"themes:\",\n\tThemeHint: \"switch with /theme \",\n\tThemeChangedFmt: \"theme switched to %s / %s\",\n\tThemeUnknownFmt: \"unknown theme %q\",\n\tLanguageHeader: \"languages:\",\n\tLanguageHint: \"switch with /language \",\n\tLanguageChangedFmt: \"language set to %s (resolved: %s)\",\n\n\tCompactionWorking: \"compacting conversation…\",\n\tCompactionTitle: \"Context compacted\",\n\tCompactionUnit: \"messages\",\n\tCompactionAuto: \"auto\",\n\tCompactionManual: \"manual\",\n\n\tSlashCompactDone: \"session compacted — older middle replaced by a summary, recent turns kept\",\n\tSlashCompactFailed: \"compaction failed\",\n\tSlashNewDone: \"fresh context started — previous transcript saved\",\n\tSlashNewFailed: \"could not start a new session\",\n\tSlashUnavailable: \"command unavailable in this build\",\n\tSlashUnknown: \"unknown command\",\n\tSlashTodoCleared: \"task list dismissed\",\n\tSlashHelp: \"commands: /compact · /new (/clear) · /resume · /rewind · /tree · /branch · /switch · /todo · /verbose · /model (switch model) · /effort · /theme · /language · /mcp · /skills · /hooks · /paste-image · /memory · /remember · /quit · /help · plus skills (/init, /explore, …)\",\n\n\tSkillPickerTitle: \"Skills\",\n\tSkillPickerAvailableFmt: \"%d available\",\n\tSkillPickerMatchingFmt: \"%d matching · %d total\",\n\tSkillPickerHint: \"↑↓ navigate · Space toggle · Enter save · / search · s sources · r rescan · Esc cancel\",\n\tSkillPickerDetailHint: \"↑↓ navigate · Enter select · Space toggle · Esc back\",\n\tSkillPickerSearchEmpty: \"No skills match your search\",\n\tSkillPickerSearchPrompt: \"search: \",\n\tSkillPickerSearchPlaceholder: \"Search skills...\",\n\tSkillPickerSourceTitle: \"Sources\",\n\tSkillPickerSourceActiveFmt: \"%d active\",\n\tSkillPickerSourceHint: \"↑↓ navigate · Enter skills · d diagnostics · s skills · r rescan · Esc close\",\n\tSkillPickerDiagHidden: \"d show diagnostics\",\n\tSkillPickerDiagShown: \"d hide diagnostics\",\n\tSkillPickerBuiltinSource: \"builtin\",\n\tSkillPickerRescanned: \"skills rescanned\",\n\tSkillPickerNoDescription: \"(no description)\",\n\tSkillPickerScopeProject: \"project\",\n\tSkillPickerScopeCustom: \"custom\",\n\tSkillPickerScopeGlobal: \"global\",\n\tSkillPickerScopeBuiltin: \"builtin\",\n\tSkillPickerSubagent: \"subagent\",\n\tSkillPickerAvailableLabel: \"on\",\n\tSkillPickerDisabledLabel: \"off\",\n\tSkillPickerNoChanges: \"Skills dialog dismissed: No changes\",\n\tSkillPickerSourceSkillsHint: \"↑↓ navigate · Space toggle · Enter details · Esc sources\",\n\tSkillPickerSourceSkillsEmpty: \"No skills in this source\",\n\tSkillPickerActionToggle: \"Toggle enabled\",\n\tSkillPickerActionDelete: \"Delete skill\",\n\tSkillPickerDeleteTitleFmt: \"Delete skill /%s?\",\n\tSkillPickerDeleteConfirm: \"Delete\",\n\tSkillPickerDeleteCancel: \"Cancel\",\n\tSkillPickerDeleteHint: \"Enter confirm · y delete · n/Esc cancel\",\n\tSkillPickerDeletedFmt: \"deleted skill %s\",\n\tSkillPickerMoreAboveFmt: \"↑ %d more above\",\n\tSkillPickerMoreBelowFmt: \"↓ %d more below\",\n\tSkillPickerTokenFmt: \"~%d tok\",\n\tSkillPickerDetailMetaFmt: \"Scope: %s Run as: %s\",\n\tSkillPickerSkillsUnit: \"skills\",\n\tSkillPickerLinesUnit: \"lines\",\n\tSkillPickerStatusLabel: \"skill picker\",\n\tSkillPickerStatusOK: \"ok\",\n\tSkillPickerStatusMissing: \"missing\",\n\tSkillPickerStatusNotDir: \"not-directory\",\n\tSkillPickerStatusUnreadable: \"unreadable\",\n\tSlashPromptEmpty: \"the MCP prompt returned no content to send\",\n\tSlashMCPNone: \"no MCP servers configured — add a [[plugins]] entry in reasonix.toml\",\n\tCtrlCQuitHint: \"press Ctrl+C again to quit\",\n\tCompHintSlash: \"↑/↓ move · Tab/Enter select · Esc close\",\n\tCompHintFile: \"↑/↓ move · Tab/Enter open folder or pick file · Esc close\",\n\n\tShellExecEmpty: \"usage: ! (e.g. !ls -la)\",\n\tShellExecFailedFmt: \"shell command failed: %v\",\n\tShellExecTimeoutFmt: \"shell command timed out (> %s)\",\n\tShellModeHint: \"Enter runs shell · Esc cancels · click output to expand\",\n\n\tCmdNew: \"start fresh context; save transcript\",\n\tCmdCompact: \"compact context\",\n\tCmdRewind: \"rewind to an earlier turn\",\n\tCmdTree: \"show conversation branches\",\n\tCmdBranch: \"create a conversation branch\",\n\tCmdSwitchBranch: \"switch conversation branch\",\n\tCmdResume: \"resume a saved session\",\n\tCmdModel: \"switch model\",\n\tCmdMemory: \"show memory files\",\n\tCmdRemember: \"save a memory note\",\n\tCmdForget: \"delete a saved memory\",\n\tCmdMcp: \"MCP servers\",\n\tCmdHooks: \"manage hooks\",\n\tCmdPasteImage: \"paste clipboard image\",\n\tCmdOutputStyle: \"list output styles\",\n\tCmdTheme: \"switch CLI theme\",\n\tCmdLanguage: \"switch CLI language\",\n\tCmdSkill: \"manage skills\",\n\tCmdVerbose: \"toggle thinking text\",\n\tCmdSandbox: \"show sandbox status\",\n\tCmdEffort: \"set reasoning effort\",\n\tCmdAutoPlan: \"configure automatic plan mode\",\n\tCmdHelp: \"list commands\",\n\tCmdTodo: \"dismiss the task list\",\n\tCmdQuit: \"exit the session\",\n\tArgSkillList: \"list skills\",\n\tArgSkillShow: \"show a skill's body\",\n\tArgSkillNew: \"scaffold a new skill\",\n\tArgSkillPaths: \"show discovery paths\",\n\tArgMcpAdd: \"connect a server\",\n\tArgMcpRemove: \"disconnect a server\",\n\tArgMcpList: \"show configured servers\",\n\tArgMcpConnected: \"connected\",\n\tArgHooksList: \"list active hooks\",\n\tArgHooksTrust: \"trust this project's hooks\",\n\tArgModelCurrent: \"current\",\n\tArgEffortAuto: \"use the model default\",\n\tArgEffortLow: \"lighter reasoning\",\n\tArgEffortMedium: \"balanced reasoning\",\n\tArgEffortHigh: \"deeper reasoning\",\n\tArgEffortXHigh: \"extra deep reasoning\",\n\tArgEffortMax: \"maximum reasoning\",\n\tArgThemeCurrent: \"current\",\n\tArgLanguageAuto: \"auto-detect from REASONIX_LANG / locale\",\n\tArgLanguageEn: \"English\",\n\tArgLanguageZh: \"中文\",\n\n\tListModelsHeaderFmt: \"models (active: %s)\",\n\tListModelsHint: \"switch with the model switcher, or type /model \",\n\tListMemoryHeader: \"memory files\",\n\tListMemoryNone: \"memory: none — add with “/remember ” or run /init to generate AGENTS.md\",\n\tListSkillsHeaderFmt: \"skills (%d)\",\n\tListSkillsNone: \"skills: none defined — invoke a built-in like /init, or author one with install_skill\",\n\tListHooksHeaderFmt: \"hooks (%d active)\",\n\tListHooksNone: \"hooks: none active — configure in .reasonix/settings.json (project, after trust) or ~/.reasonix/settings.json (global)\",\n\tListMcpHeader: \"mcp servers\",\n\tListMcpNone: \"mcp: no servers connected — add one in reasonix.toml ([[plugins]]) or a project .mcp.json\",\n\n\tMemoryNone: \"memory: none — add with “/remember ” or create REASONIX.md in the project root\",\n\tMemoryLoaded: \"memory loaded:\",\n\tMemorySavedHeader: \" saved memories (delete with “/forget ”):\",\n\tMemoryStoredUnderFmt: \" stored under %s\",\n\tMemoryEditHint: \"edit doc files or use “/remember ”; doc edits apply next session\",\n\tForgetUsage: \"usage: /forget — the slug shown under “saved memories” in /memory\",\n\tForgetDoneFmt: \"forgot memory: %s\",\n\tQuickRememberEmpty: \"nothing to remember\",\n\tQuickRememberDoneFmt: \"remembered → %s\",\n\tModelSwitchUnavailable: \"model switching is unavailable in this session\",\n\tModelSwitchBusy: \"finish or cancel the current turn before switching models\",\n\tModelAlreadyOnFmt: \"already on %s\",\n\tModelSwitchingFmt: \"switching to %s…\",\n\tModelSwitchedFmt: \"switched to %s (conversation carried over; prompt cache resets)\",\n\tModelListHeader: \"models (/model to switch)\",\n\tRewindNone: \"nothing to rewind yet\",\n\tRewindCodeConversation: \"Code + conversation\",\n\tRewindConversationOnly: \"Conversation only\",\n\tRewindCodeOnly: \"Code only\",\n\tRewindFork: \"Fork (new branch, keep code)\",\n\tRewindSummarizeFrom: \"Summarize from here\",\n\tRewindSummarizeUpto: \"Summarize up to here\",\n\tRewindPickTitle: \"⟲ Rewind — pick a turn\",\n\tRewindPickHint: \"↑/↓ move · Enter choose · Esc close\",\n\tRewindRestoreTitleFmt: \"⟲ Restore to turn %d \",\n\tRewindApplyHint: \"↑/↓ · Enter apply · Esc back\",\n\tRewindEmpty: \"(empty)\",\n\n\tSelectProvidersLabel: \"Select providers to enable\",\n\tEnterAPIKeysHeader: \"Enter API keys (Enter to skip and set later):\",\n\tMissingKeyIntro: \"reasonix.toml is ready — just an API key away.\",\n\tWroteFileFmt: \"Wrote %s\",\n\tSetupComplete: \"Setup complete.\",\n\tSetupCancelled: \"setup cancelled.\",\n\tTryHintFmt: \"Try: %s\",\n\tNextHint: \"Next: set your API key (run `reasonix setup` or export DEEPSEEK_API_KEY=...), then run `reasonix run \\\"your task\\\"`.\",\n\tConfirmReconfigureFmt: \"%s already exists. Reconfigure and overwrite?\",\n\tKeepingExisting: \"Keeping existing config.\",\n\tNotOverwritingFmt: \"%s already exists; not overwriting\",\n\n\t// model fetching\n\tFetchingModelsFmt: \"Fetching models for %s...\",\n\tFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tFetchModelsUsingPresetsFmt: \"Live fetch unavailable for %s, using preset model list\",\n\tFamilyKeyPromptFmt: \"Enter your %s API key to list available models (Enter to skip):\",\n\tSelectModelsLabel: \"Select models to enable for %s\",\n\tNoModelsAvailableFmt: \"%s: no models available, skipping\",\n\tCustomFetchEmpty: \"/models returned an empty list — falling back to manual entry\",\n\tAnthropicFetchEmpty: \"/models returned an empty list — Anthropic-compatible providers usually don't expose one, falling back to manual entry\",\n\tSkipStaleCustomEntryFmt: \"skipping stale %q entry from reasonix.toml (pointing at %s) — please remove it from [[providers]]\",\n\tAPIKeyAlreadySetFmt: \"reusing existing value for %s\",\n\tAPIKeyResetPromptFmt: \"Re-enter %s?\",\n\n\t// custom provider\n\tCustomProviderLabel: \"Custom Model\",\n\tCustomProviderDesc: \"Add third-party OpenAI compatible model\",\n\tCustomAddMethodLabel: \"Add third-party OpenAI compatible model - Select add method\",\n\tCustomMethodManual: \"Enter model name manually\",\n\tCustomMethodURL: \"Fetch models from URL\",\n\tCustomPromptModel: \"Enter model name\",\n\tCustomPromptBaseURL: \"Enter Base URL\",\n\tCustomPromptKeyEnv: \"Enter API Key env var name\",\n\tCustomPromptAPIKey: \"Enter API Key\",\n\tCustomAddedFmt: \"Added custom model: %s\",\n\n\t// Anthropic compatible provider\n\tAnthropicProviderLabel: \"Custom Model 2\",\n\tAnthropicProviderDesc: \"Add third-party Anthropic compatible model\",\n\tAnthropicAddMethodLabel: \"Add third-party Anthropic compatible model - Select add method\",\n\tAnthropicMethodManual: \"Enter model name manually\",\n\tAnthropicMethodURL: \"Fetch models from URL\",\n\tAnthropicPromptModel: \"Enter model name\",\n\tAnthropicPromptBaseURL: \"Enter Base URL\",\n\tAnthropicPromptKeyEnv: \"Enter API Key env var name\",\n\tAnthropicPromptAPIKey: \"Enter API Key\",\n\tAnthropicAddedFmt: \"Added Anthropic compatible model: %s\",\n\tAnthropicFetchingModelsFmt: \"Fetching models for %s...\",\n\tAnthropicFetchModelsSuccessFmt: \"Found %d models for %s\",\n\tAnthropicFetchModelsFailedFmt: \"Failed to fetch models for %s: %v\",\n\tAnthropicSelectModelsLabel: \"Select models to enable for %s\",\n\n\tUnknownCommandFmt: \"unknown command %q\",\n\tUsageRunHint: \"usage: reasonix run [--model NAME] \",\n\tErrorPrefix: \"error:\",\n\tReconfigureOnUnknownModel: \"Configured model is no longer available — re-running setup.\",\n\tWriteConfigErr: \"write config:\",\n\tWriteEnvErr: \"write .env:\",\n\n\tProviderErrBadRequest: \"Malformed request (HTTP 400): the request body was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrAuth: \"Authentication failed (HTTP 401): your API key is missing, wrong, or expired. Check the key in .env or run `reasonix setup`.\",\n\tProviderErrInsufficientBalance: \"Insufficient balance (HTTP 402): your account is out of credit. Top up your account, then retry.\",\n\tProviderErrUnprocessable: \"Invalid parameters (HTTP 422): a request parameter was rejected. This is likely a bug — please report it if it persists.\",\n\tProviderErrRateLimited: \"Rate limit reached (HTTP 429): too many requests (TPM/RPM). Retried with backoff — slow down or try again shortly.\",\n\tProviderErrServer: \"Server error (HTTP 500): the provider hit an internal fault. Retried with backoff; if it keeps failing, try again later.\",\n\tProviderErrServerBusy: \"Server busy (HTTP 503): the provider is overloaded. Retried with backoff; please try again shortly.\",\n\n\tSelectOneHint: \"(↑/↓ · Enter · q to cancel)\",\n\tSelectManyHint: \"(↑/↓ · Space · Enter · q)\",\n\n\tUsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)\n\nUsage:\n reasonix chat [--model NAME] [-c|--continue] [--resume] interactive session (multi-turn; -c resumes the latest, --resume picks one)\n reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] run one task and exit\n reasonix serve [--model NAME] [--addr HOST:PORT] serve the session over HTTP+SSE (browser client at /)\n reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp)\n reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env)\n reasonix config auto-plan [off|on] configure automatic plan mode\n reasonix mcp manage MCP servers in reasonix.toml\n reasonix doctor [--json] print redacted local diagnostics\n reasonix version\n reasonix help\n\nExamples:\n reasonix chat\n reasonix chat --continue\n reasonix run \"implement the TODOs in main.go\"\n reasonix run --model mimo-pro \"add unit tests for this function\"\n echo \"explain this code\" | reasonix run\n\nConfiguration:\n Resolution: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults\n Secrets come from the environment via api_key_env (e.g. DEEPSEEK_API_KEY).\n Run 'reasonix setup' to scaffold a config; see docs/SPEC.md.\n`,\n}\n"}}, "desktop/app.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785745647.3422801, "n": 186930, "h": "962811965821d512", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n\n\t// cleanupWg tracks fire-and-forget session cleanup goroutines so shutdown\n\t// can wait for them to finish before the process exits.\n\tcleanupWg sync.WaitGroup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tslog.Error(\"crypto/rand.Read failed; media preview unavailable\", \"err\", err)\n\t\treturn \"\"\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tstartupCfg, cfgErr := config.Load()\n\tif cfgErr == nil {\n\t\tcfg := startupCfg\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\tif cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {\n\t\tf = singleSurfaceTabsFile(f)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\t// Wait for any in-flight delayed session cleanup goroutines before tearing\n\t// down controllers and shared hosts — they may still hold session handles.\n\ta.cleanupWg.Wait()\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\ta.cleanupWg.Add(1)\n\t\t\tgo func() {\n\t\t\t\tdefer a.cleanupWg.Done()\n\t\t\t\tdelayedDesktopSessionCleanup(oldPath, destroys)\n\t\t\t}()\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\ta.cleanupWg.Add(1)\n\t\tgo func() {\n\t\t\tdefer a.cleanupWg.Done()\n\t\t\tdelayedDesktopSessionCleanup(oldPath, destroys)\n\t\t}()\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\ta.cleanupWg.Add(1)\n\t\tgo func() {\n\t\t\tdefer a.cleanupWg.Done()\n\t\t\tdelayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t\t}()\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif a.singleSurfaceLayoutEnabled() {\n\t\t_, err = a.ActivateTopic(scope, root, topicID, \"\")\n\t} else if scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\n\tvar closeTabs []*WorkspaceTab\n\tvar closeDetached []*WorkspaceTab\n\tvar fallback *WorkspaceTab\n\ta.mu.Lock()\n\tfor _, tab := range a.tabs {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor _, tab := range a.detachedSessions {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor id, tab := range a.tabs {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl != nil && !tab.ReadOnly {\n\t\t\t_ = tab.Ctrl.Snapshot()\n\t\t}\n\t\ta.markTabRemovedLocked(tab)\n\t\tcloseTabs = append(closeTabs, tab)\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tcloseDetached = append(closeDetached, tab)\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif len(a.tabs) == 0 {\n\t\tfallback = a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\t\tfallback.TopicTitle = \"Global\"\n\t\tfallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}\n\t\ta.tabs[fallback.ID] = fallback\n\t\ta.tabOrder = append(a.tabOrder, fallback.ID)\n\t\ta.activeTabID = fallback.ID\n\t} else if a.activeTabID == \"\" {\n\t\tif ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {\n\t\t\ta.activeTabID = ordered[0]\n\t\t}\n\t}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\tfor _, tab := range closeTabs {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tfor _, tab := range closeDetached {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tif fallback != nil {\n\t\ta.startTabControllerBuild(fallback)\n\t}\n\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tvar meta TabMeta\n\tif a.singleSurfaceLayoutEnabled() {\n\t\tmeta, err = a.ActivateTopic(\"project\", dir, topic.ID, \"\")\n\t} else {\n\t\tmeta, err = a.OpenProjectTab(dir, topic.ID)\n\t}\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\nfunc (a *App) singleSurfaceLayoutEnabled() bool {\n\tcfg, _, err := a.loadDesktopUserConfigForView()\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tif token != \"\" {\n\t\t\tout.Kind = kind\n\t\t\tout.Mime = mime\n\t\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\t}\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o600); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn !config.CredentialStored(onboardingKeyEnv)\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to\n// Reasonix's global .env, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "b44c5aaae6716a6da0498f4a332de6d9538d438d": {"t": 1785750390.0168946, "n": 186930, "h": "962811965821d512", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n\n\t// cleanupWg tracks fire-and-forget session cleanup goroutines so shutdown\n\t// can wait for them to finish before the process exits.\n\tcleanupWg sync.WaitGroup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tslog.Error(\"crypto/rand.Read failed; media preview unavailable\", \"err\", err)\n\t\treturn \"\"\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tstartupCfg, cfgErr := config.Load()\n\tif cfgErr == nil {\n\t\tcfg := startupCfg\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\tif cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {\n\t\tf = singleSurfaceTabsFile(f)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\t// Wait for any in-flight delayed session cleanup goroutines before tearing\n\t// down controllers and shared hosts — they may still hold session handles.\n\ta.cleanupWg.Wait()\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\ta.cleanupWg.Add(1)\n\t\t\tgo func() {\n\t\t\t\tdefer a.cleanupWg.Done()\n\t\t\t\tdelayedDesktopSessionCleanup(oldPath, destroys)\n\t\t\t}()\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\ta.cleanupWg.Add(1)\n\t\tgo func() {\n\t\t\tdefer a.cleanupWg.Done()\n\t\t\tdelayedDesktopSessionCleanup(oldPath, destroys)\n\t\t}()\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\ta.cleanupWg.Add(1)\n\t\tgo func() {\n\t\t\tdefer a.cleanupWg.Done()\n\t\t\tdelayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t\t}()\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif a.singleSurfaceLayoutEnabled() {\n\t\t_, err = a.ActivateTopic(scope, root, topicID, \"\")\n\t} else if scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\n\tvar closeTabs []*WorkspaceTab\n\tvar closeDetached []*WorkspaceTab\n\tvar fallback *WorkspaceTab\n\ta.mu.Lock()\n\tfor _, tab := range a.tabs {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor _, tab := range a.detachedSessions {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor id, tab := range a.tabs {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl != nil && !tab.ReadOnly {\n\t\t\t_ = tab.Ctrl.Snapshot()\n\t\t}\n\t\ta.markTabRemovedLocked(tab)\n\t\tcloseTabs = append(closeTabs, tab)\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tcloseDetached = append(closeDetached, tab)\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif len(a.tabs) == 0 {\n\t\tfallback = a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\t\tfallback.TopicTitle = \"Global\"\n\t\tfallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}\n\t\ta.tabs[fallback.ID] = fallback\n\t\ta.tabOrder = append(a.tabOrder, fallback.ID)\n\t\ta.activeTabID = fallback.ID\n\t} else if a.activeTabID == \"\" {\n\t\tif ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {\n\t\t\ta.activeTabID = ordered[0]\n\t\t}\n\t}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\tfor _, tab := range closeTabs {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tfor _, tab := range closeDetached {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tif fallback != nil {\n\t\ta.startTabControllerBuild(fallback)\n\t}\n\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tvar meta TabMeta\n\tif a.singleSurfaceLayoutEnabled() {\n\t\tmeta, err = a.ActivateTopic(\"project\", dir, topic.ID, \"\")\n\t} else {\n\t\tmeta, err = a.OpenProjectTab(dir, topic.ID)\n\t}\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\nfunc (a *App) singleSurfaceLayoutEnabled() bool {\n\tcfg, _, err := a.loadDesktopUserConfigForView()\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tif token != \"\" {\n\t\t\tout.Kind = kind\n\t\t\tout.Mime = mime\n\t\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\t}\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o600); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn !config.CredentialStored(onboardingKeyEnv)\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to\n// Reasonix's global .env, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "3c1bc90980dfd3a280f12ba588255f66b436626d": {"t": 1785750391.610426, "n": 186864, "h": "b5256f6679658bdd", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n\n\t// cleanupWg tracks fire-and-forget session cleanup goroutines so shutdown\n\t// can wait for them to finish before the process exits.\n\tcleanupWg sync.WaitGroup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tstartupCfg, cfgErr := config.Load()\n\tif cfgErr == nil {\n\t\tcfg := startupCfg\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\tif cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {\n\t\tf = singleSurfaceTabsFile(f)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\t// Wait for any in-flight delayed session cleanup goroutines before tearing\n\t// down controllers and shared hosts — they may still hold session handles.\n\ta.cleanupWg.Wait()\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\ta.cleanupWg.Add(1)\n\t\t\tgo func() {\n\t\t\t\tdefer a.cleanupWg.Done()\n\t\t\t\tdelayedDesktopSessionCleanup(oldPath, destroys)\n\t\t\t}()\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\ta.cleanupWg.Add(1)\n\t\tgo func() {\n\t\t\tdefer a.cleanupWg.Done()\n\t\t\tdelayedDesktopSessionCleanup(oldPath, destroys)\n\t\t}()\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\ta.cleanupWg.Add(1)\n\t\tgo func() {\n\t\t\tdefer a.cleanupWg.Done()\n\t\t\tdelayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t\t}()\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif a.singleSurfaceLayoutEnabled() {\n\t\t_, err = a.ActivateTopic(scope, root, topicID, \"\")\n\t} else if scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\n\tvar closeTabs []*WorkspaceTab\n\tvar closeDetached []*WorkspaceTab\n\tvar fallback *WorkspaceTab\n\ta.mu.Lock()\n\tfor _, tab := range a.tabs {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor _, tab := range a.detachedSessions {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor id, tab := range a.tabs {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl != nil && !tab.ReadOnly {\n\t\t\t_ = tab.Ctrl.Snapshot()\n\t\t}\n\t\ta.markTabRemovedLocked(tab)\n\t\tcloseTabs = append(closeTabs, tab)\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tcloseDetached = append(closeDetached, tab)\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif len(a.tabs) == 0 {\n\t\tfallback = a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\t\tfallback.TopicTitle = \"Global\"\n\t\tfallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}\n\t\ta.tabs[fallback.ID] = fallback\n\t\ta.tabOrder = append(a.tabOrder, fallback.ID)\n\t\ta.activeTabID = fallback.ID\n\t} else if a.activeTabID == \"\" {\n\t\tif ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {\n\t\t\ta.activeTabID = ordered[0]\n\t\t}\n\t}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\tfor _, tab := range closeTabs {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tfor _, tab := range closeDetached {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tif fallback != nil {\n\t\ta.startTabControllerBuild(fallback)\n\t}\n\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tvar meta TabMeta\n\tif a.singleSurfaceLayoutEnabled() {\n\t\tmeta, err = a.ActivateTopic(\"project\", dir, topic.ID, \"\")\n\t} else {\n\t\tmeta, err = a.OpenProjectTab(dir, topic.ID)\n\t}\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\nfunc (a *App) singleSurfaceLayoutEnabled() bool {\n\tcfg, _, err := a.loadDesktopUserConfigForView()\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o600); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn !config.CredentialStored(onboardingKeyEnv)\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to\n// Reasonix's global .env, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "4fe39d3092146290b1c60b2a77f869909f77f74e": {"t": 1785750393.199112, "n": 186324, "h": "df95279803d995c0", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tstartupCfg, cfgErr := config.Load()\n\tif cfgErr == nil {\n\t\tcfg := startupCfg\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\tif cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {\n\t\tf = singleSurfaceTabsFile(f)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif a.singleSurfaceLayoutEnabled() {\n\t\t_, err = a.ActivateTopic(scope, root, topicID, \"\")\n\t} else if scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\n\tvar closeTabs []*WorkspaceTab\n\tvar closeDetached []*WorkspaceTab\n\tvar fallback *WorkspaceTab\n\ta.mu.Lock()\n\tfor _, tab := range a.tabs {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor _, tab := range a.detachedSessions {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor id, tab := range a.tabs {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl != nil && !tab.ReadOnly {\n\t\t\t_ = tab.Ctrl.Snapshot()\n\t\t}\n\t\ta.markTabRemovedLocked(tab)\n\t\tcloseTabs = append(closeTabs, tab)\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tcloseDetached = append(closeDetached, tab)\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif len(a.tabs) == 0 {\n\t\tfallback = a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\t\tfallback.TopicTitle = \"Global\"\n\t\tfallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}\n\t\ta.tabs[fallback.ID] = fallback\n\t\ta.tabOrder = append(a.tabOrder, fallback.ID)\n\t\ta.activeTabID = fallback.ID\n\t} else if a.activeTabID == \"\" {\n\t\tif ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {\n\t\t\ta.activeTabID = ordered[0]\n\t\t}\n\t}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\tfor _, tab := range closeTabs {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tfor _, tab := range closeDetached {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tif fallback != nil {\n\t\ta.startTabControllerBuild(fallback)\n\t}\n\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tvar meta TabMeta\n\tif a.singleSurfaceLayoutEnabled() {\n\t\tmeta, err = a.ActivateTopic(\"project\", dir, topic.ID, \"\")\n\t} else {\n\t\tmeta, err = a.OpenProjectTab(dir, topic.ID)\n\t}\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\nfunc (a *App) singleSurfaceLayoutEnabled() bool {\n\tcfg, _, err := a.loadDesktopUserConfigForView()\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn !config.CredentialStored(onboardingKeyEnv)\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to\n// Reasonix's global .env, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "99c09ae7e348de2b0a4e5134e85543cfd07f4894": {"t": 1785750394.7718143, "n": 186313, "h": "d73c43b4d6eb1bc6", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tstartupCfg, cfgErr := config.Load()\n\tif cfgErr == nil {\n\t\tcfg := startupCfg\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\tif cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {\n\t\tf = singleSurfaceTabsFile(f)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif a.singleSurfaceLayoutEnabled() {\n\t\t_, err = a.ActivateTopic(scope, root, topicID, \"\")\n\t} else if scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\n\tvar closeTabs []*WorkspaceTab\n\tvar closeDetached []*WorkspaceTab\n\tvar fallback *WorkspaceTab\n\ta.mu.Lock()\n\tfor _, tab := range a.tabs {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor _, tab := range a.detachedSessions {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor id, tab := range a.tabs {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl != nil && !tab.ReadOnly {\n\t\t\t_ = tab.Ctrl.Snapshot()\n\t\t}\n\t\ta.markTabRemovedLocked(tab)\n\t\tcloseTabs = append(closeTabs, tab)\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tcloseDetached = append(closeDetached, tab)\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif len(a.tabs) == 0 {\n\t\tfallback = a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\t\tfallback.TopicTitle = \"Global\"\n\t\tfallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}\n\t\ta.tabs[fallback.ID] = fallback\n\t\ta.tabOrder = append(a.tabOrder, fallback.ID)\n\t\ta.activeTabID = fallback.ID\n\t} else if a.activeTabID == \"\" {\n\t\tif ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {\n\t\t\ta.activeTabID = ordered[0]\n\t\t}\n\t}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\tfor _, tab := range closeTabs {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tfor _, tab := range closeDetached {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tif fallback != nil {\n\t\ta.startTabControllerBuild(fallback)\n\t}\n\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tvar meta TabMeta\n\tif a.singleSurfaceLayoutEnabled() {\n\t\tmeta, err = a.ActivateTopic(\"project\", dir, topic.ID, \"\")\n\t} else {\n\t\tmeta, err = a.OpenProjectTab(dir, topic.ID)\n\t}\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\nfunc (a *App) singleSurfaceLayoutEnabled() bool {\n\tcfg, _, err := a.loadDesktopUserConfigForView()\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn !config.CredentialStored(onboardingKeyEnv)\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to\n// Reasonix's global .env, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "bf159d0b568f6396d8a65ba11ad51bfe7f29e1c8": {"t": 1785750396.36018, "n": 186283, "h": "3e042042d10de698", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tstartupCfg, cfgErr := config.Load()\n\tif cfgErr == nil {\n\t\tcfg := startupCfg\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\tif cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {\n\t\tf = singleSurfaceTabsFile(f)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif a.singleSurfaceLayoutEnabled() {\n\t\t_, err = a.ActivateTopic(scope, root, topicID, \"\")\n\t} else if scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\n\tvar closeTabs []*WorkspaceTab\n\tvar closeDetached []*WorkspaceTab\n\tvar fallback *WorkspaceTab\n\ta.mu.Lock()\n\tfor _, tab := range a.tabs {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor _, tab := range a.detachedSessions {\n\t\tif tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {\n\t\t\ta.mu.Unlock()\n\t\t\treturn fmt.Errorf(\"workspace has running sessions; stop them before removing\")\n\t\t}\n\t}\n\tfor id, tab := range a.tabs {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tcloseTabs = append(closeTabs, tab)\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabInWorkspace(tab, dir) {\n\t\t\tcontinue\n\t\t}\n\t\tcloseDetached = append(closeDetached, tab)\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif len(a.tabs) == 0 {\n\t\tfallback = a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\t\tfallback.TopicTitle = \"Global\"\n\t\tfallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}\n\t\ta.tabs[fallback.ID] = fallback\n\t\ta.tabOrder = append(a.tabOrder, fallback.ID)\n\t\ta.activeTabID = fallback.ID\n\t} else if a.activeTabID == \"\" {\n\t\tif ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {\n\t\t\ta.activeTabID = ordered[0]\n\t\t}\n\t}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\tfor _, tab := range closeTabs {\n\t\tif tab.Ctrl != nil && !tab.ReadOnly {\n\t\t\t_ = tab.Ctrl.Snapshot()\n\t\t}\n\t\ta.closeTabRuntime(tab)\n\t}\n\tfor _, tab := range closeDetached {\n\t\ta.closeTabRuntime(tab)\n\t}\n\tif fallback != nil {\n\t\ta.startTabControllerBuild(fallback)\n\t}\n\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tvar meta TabMeta\n\tif a.singleSurfaceLayoutEnabled() {\n\t\tmeta, err = a.ActivateTopic(\"project\", dir, topic.ID, \"\")\n\t} else {\n\t\tmeta, err = a.OpenProjectTab(dir, topic.ID)\n\t}\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\nfunc (a *App) singleSurfaceLayoutEnabled() bool {\n\tcfg, _, err := a.loadDesktopUserConfigForView()\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn !config.CredentialStored(onboardingKeyEnv)\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to\n// Reasonix's global .env, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "2dd6fc6ddf40c3a04544e2a706404755c6dc0757": {"t": 1785750397.933581, "n": 184045, "h": "2b7502066d8141cb", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn !config.CredentialStored(onboardingKeyEnv)\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to\n// Reasonix's global .env, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "7e7c7eda097114c87b2247e036c0df8311bd7c62": {"t": 1785750399.5301955, "n": 184055, "h": "1aba171a19b59a7f", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to\n// Reasonix's global .env, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "a2f335336abaa6d7499e2a35ba28d424d4b2ad6b": {"t": 1785750401.511315, "n": 184071, "h": "c633df42d6cd9666", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "d26572c27c1fede8cda84efd21b7ebf15478f831": {"t": 1785750403.3474417, "n": 184060, "h": "a989cb4cef65acd8", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "76c8a07e57899f5609f994af63961472478d18b0": {"t": 1785750404.9539576, "n": 183874, "h": "763c67bacbedb649", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "05cf7a8b5330b9bec1f8cedc88c2226cbe2510e1": {"t": 1785750407.1180928, "n": 183390, "h": "0542e5cfa85dd58f", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "51eea606e756052c352f70779585ad234659efb9": {"t": 1785750408.6957128, "n": 183914, "h": "0ee757c148684535", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tseenRoots := map[string]int{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tif idx, ok := seenRoots[dir]; ok {\n\t\t\tout[idx] = mergeDuplicateSkillRootView(out[idx], view)\n\t\t\tcontinue\n\t\t}\n\t\tseenRoots[dir] = len(out)\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {\n\texisting.Configured = existing.Configured || duplicate.Configured\n\texisting.Removable = existing.Removable || duplicate.Removable\n\tif existing.Status != \"ok\" && duplicate.Status == \"ok\" {\n\t\texisting.Status = duplicate.Status\n\t}\n\tif existing.Skills == 0 && duplicate.Skills > 0 {\n\t\texisting.Skills = duplicate.Skills\n\t\texisting.SkillItems = duplicate.SkillItems\n\t}\n\tif existing.Warning == \"\" {\n\t\texisting.Warning = duplicate.Warning\n\t}\n\treturn existing\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "ce549166480138bd7d88aca17255d18b90de271e": {"t": 1785750410.290481, "n": 183204, "h": "057e9e062d9133da", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "e19fbdcc00749b2a180f8597042a72fd94f33176": {"t": 1785750411.9319153, "n": 183039, "h": "1c3144c558ef34a0", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tHeaderKeys []string `json:\"headerKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tv.EnvKeys = nil\n\tv.HeaderKeys = nil\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tif len(p.Headers) > 0 {\n\t\tv.HeaderKeys = make([]string, 0, len(p.Headers))\n\t\tfor k := range p.Headers {\n\t\t\tv.HeaderKeys = append(v.HeaderKeys, k)\n\t\t}\n\t\tsort.Strings(v.HeaderKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n\tHeaders map[string]string `json:\"headers\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t\tHeaders: in.Headers,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tif in.Headers != nil {\n\t\tupdated.Headers = in.Headers\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "87b40dfe8705cdeb7c6ef52f9599916d7fc79f8c": {"t": 1785750413.760163, "n": 182800, "h": "9bfbb205371c75a8", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // cumulative files RestoreCode would affect from this turn\n\tTurnFileCount int `json:\"turnFileCount\"` // files changed during this turn only\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTurnFileCount: len(m.Paths),\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "42734604235b85d3939ac9d8e20f9ed0a312fda9": {"t": 1785750416.1250608, "n": 182646, "h": "7b7b846ea9a99ade", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "47166a37925ca706ff0e0726ab1d97d59cf4b2f5": {"t": 1785750417.7201562, "n": 182635, "h": "9aa845c4b49ab2ad", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tfor _, p := range sessionOwnedArtifactPaths(path) {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "4da0a184c390f5b240cf1326e5327ca1e8711548": {"t": 1785750419.3042982, "n": 182867, "h": "3e02dba60691e0cf", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t\t{Name: \"reload-cmd\", Description: i18n.M.CmdReloadCmd, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// ReloadCommands rescans command directories and hot-swaps without restarting\n// the controller — no MCP disconnect, no hook rerun.\nfunc (a *App) ReloadCommands() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif tab.Ctrl.Running() {\n\t\treturn fmt.Errorf(\"wait for the current turn to finish, then retry\")\n\t}\n\treturn tab.Ctrl.ReloadCommands(a.ctx)\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "8bdc4d35b98393b275020e0967b0a007f0146254": {"t": 1785750420.869774, "n": 182528, "h": "d9afde0902f7bfab", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\tdir, entries, activeID, version := a.saveTabsCollectLocked()\n\ta.mu.Unlock()\n\n\ta.saveTabsWrite(dir, entries, activeID, version)\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "fe3bf9ccfa629dbf7623d4a8879e29fd68496e81": {"t": 1785750422.9188113, "n": 182353, "h": "a269591e280d3208", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "7ba4295565251b6911e7761bd0e825d29aa75cec": {"t": 1785750425.5475342, "n": 182342, "h": "0b3445f75d3f912e", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (enabled but idle), \"failed\" (with\n// the connection error), \"initializing\" (background startup in progress), or\n// \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tStartIntent string `json:\"startIntent,omitempty\"`\n\tRuntimeState string `json:\"runtimeState,omitempty\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.RuntimeState = \"idle\"\n\t\t\t\tdisabledView.StartIntent = \"off\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\", RuntimeState: \"ready\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", RuntimeState: \"issue\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, name := range h.ConnectingServers() {\n\t\t\tif seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tview := ServerView{Name: name, Status: \"initializing\", RuntimeState: \"connecting\"}\n\t\t\tif p, ok := configured[name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected, connecting, nor failed are\n\t// idle: disabled/off or automatic background startup waiting for its next kick.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts.RuntimeState = \"idle\"\n\t\t\t\ts.StartIntent = \"off\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tstartIntent := \"off\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tstatus = \"deferred\"\n\t\t\t\tstartIntent = mcpStartIntent(p)\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: \"idle\"}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc mcpStartIntent(p config.PluginEntry) string {\n\tif !p.ShouldAutoStart() {\n\t\treturn \"off\"\n\t}\n\treturn \"automatic\"\n}\n\nfunc mcpRuntimeState(status string) string {\n\tswitch status {\n\tcase \"connected\":\n\t\treturn \"ready\"\n\tcase \"initializing\":\n\t\treturn \"connecting\"\n\tcase \"failed\":\n\t\treturn \"issue\"\n\tdefault:\n\t\treturn \"idle\"\n\t}\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tif v.StartIntent == \"\" {\n\t\tv.StartIntent = mcpStartIntent(p)\n\t}\n\tif v.Status == \"disabled\" {\n\t\tv.StartIntent = \"off\"\n\t}\n\tif v.RuntimeState == \"\" {\n\t\tv.RuntimeState = mcpRuntimeState(v.Status)\n\t}\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "2ff0c3de3f6ca80b583929a8f6d822cff042896f": {"t": 1785750427.1322155, "n": 181355, "h": "c40f2219b7c68e99", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "d9e453a1bedd675c226660cab033da35421b141d": {"t": 1785750428.6983433, "n": 181344, "h": "f145d405e3549596", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tconfig.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "c7344b3ac547cae6766e0ea5cfaa4d882cc1577a": {"t": 1785750430.281657, "n": 181375, "h": "fd455691a6e84d5c", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, false)\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.Arguments = args\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\tcase \"mimo-token-plan\":\n\t\tfamily = []string{\"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\"}\n\tcase \"mimo-api\":\n\t\tfamily = []string{\"mimo-api\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "9fb4088d4c2b3b75ecc184dc774ba7b38f08b816": {"t": 1785750431.8372488, "n": 173183, "h": "fa0ff9d56b46fcaa", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. Keep it stable across releases so launcher/Dock/taskbar\n// reopen behavior remains predictable on every platform.\nconst singleInstanceID = \"com.reasonix.desktop\"\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil && !t.ReadOnly {\n\t\t\t_ = t.Ctrl.Snapshot()\n\t\t}\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = t.Ctrl.Snapshot()\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\n// snapshotTab persists the current session if the tab is writable.\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"` // provider: \"discord\"|\"weixin\"|\"telegram\"|etc.\n\tChannelLabel string `json:\"channelLabel,omitempty\"` // display name\n\tRemoteID string `json:\"remoteId,omitempty\"` // bot session id\n\tChatType string `json:\"chatType,omitempty\"` // \"dm\"|\"group\"\n\tUserID string `json:\"userId,omitempty\"` // discord user id / wx openid\n\tThreadID string `json:\"threadId,omitempty\"` // discord thread / wx group\n\tSessionSource string `json:\"sessionSource,omitempty\"` // \"auto\"|\"manual\"\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\n// channelSessionRoute maps bot session-mapping metadata to a transcript file.\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\n// botSessionPathTarget extracts a session file path from a bot session-mapping reference.\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\n// channelDisplayName returns a human-readable label for a bot channel.\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// channelSessionRoutesForDir reads the user config and returns a map from\n// session-runtime key → channel metadata for every auto-mapped bot session.\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\n// applyChannelSessionRoute sets channel metadata on a session meta from a route.\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tif waitDestroyHandles(destroys) {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\tseen := map[control.SessionAPI]bool{}\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.tab != nil && !releasedTabs[item.tab] {\n\t\t\treleasedTabs[item.tab] = true\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.Close()\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime) {\n\tseen := map[control.SessionAPI]bool{}\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.CloseAfterDestroy()\n\t}\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = ctrl.Snapshot() // persist the current session before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = HistoryToolCall{ID: tc.ID, Name: tc.Name, Arguments: args}\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, HistoryToolCall{ID: id, Name: name, Arguments: args})\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: firstNonEmpty(rec.Output, rec.Content),\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = tab.Ctrl.Snapshot()\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = tab.Ctrl.Snapshot()\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = oldCtrl.Snapshot()\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\tcase \"mimo-token-plan\":\n\t\tfamily = []string{\"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\"}\n\tcase \"mimo-api\":\n\t\tfamily = []string{\"mimo-api\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\n// tabReadOnly reports whether a tab is open in read-only mode (e.g. a channel transcript).\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "e89419af24a2484c89fae851f6c1f04f6d607749": {"t": 1785750433.5975778, "n": 181364, "h": "02d5aa118c0e374d", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\nconst singleInstanceIDPrefix = \"com.reasonix.desktop\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. It is stable for a given binary path, while allowing a dev\n// build and an installed release at different paths to run side by side.\nfunc singleInstanceID() string {\n\tabs, err := os.Executable()\n\tif err != nil {\n\t\treturn singleInstanceIDPrefix\n\t}\n\tif resolved, err := filepath.EvalSymlinks(abs); err == nil {\n\t\tabs = resolved\n\t} else if fallback, err := filepath.Abs(abs); err == nil {\n\t\tabs = fallback\n\t}\n\tsum := sha256.Sum256([]byte(abs))\n\treturn singleInstanceIDPrefix + \".\" + hex.EncodeToString(sum[:8])\n}\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := a.prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tclosedRemoved := map[control.SessionAPI]bool{}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\ta.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem.ctrl.SetSessionPath(\"\")\n\t\ta.quiesceTabAutosave(item.tab)\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\ta.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})\n}\n\nfunc (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, false)\n\t}\n}\n\nfunc (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\ta.closeRemovedSessionRuntime(item, closed, releasedTabs, true)\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {\n\tif item.tab != nil {\n\t\tif releasedTabs == nil || !releasedTabs[item.tab] {\n\t\t\tif releasedTabs != nil {\n\t\t\t\treleasedTabs[item.tab] = true\n\t\t\t}\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t}\n\tif item.ctrl == nil {\n\t\treturn\n\t}\n\tif closed == nil {\n\t\tclosed = map[control.SessionAPI]bool{}\n\t}\n\tif closed[item.ctrl] {\n\t\treturn\n\t}\n\tclosed[item.ctrl] = true\n\tif afterDestroy {\n\t\titem.ctrl.CloseAfterDestroy()\n\t\treturn\n\t}\n\titem.ctrl.Close()\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\tcase \"mimo-token-plan\":\n\t\tfamily = []string{\"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\"}\n\tcase \"mimo-api\":\n\t\tfamily = []string{\"mimo-api\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "dbb6f83acde4f4c5f0d77a3931acd39b8234f38c": {"t": 1785750435.173959, "n": 179633, "h": "495896f155202bdc", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. Keep it stable across releases so launcher/Dock/taskbar\n// reopen behavior remains predictable on every platform.\nconst singleInstanceID = \"com.reasonix.desktop\"\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl control.SessionAPI\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl control.SessionAPI) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tif waitDestroyHandles(destroys) {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl control.SessionAPI\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl control.SessionAPI) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\tseen := map[control.SessionAPI]bool{}\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.tab != nil && !releasedTabs[item.tab] {\n\t\t\treleasedTabs[item.tab] = true\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.Close()\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime) {\n\tseen := map[control.SessionAPI]bool{}\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.CloseAfterDestroy()\n\t}\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl control.SessionAPI\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl control.SessionAPI, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\tcase \"mimo-token-plan\":\n\t\tfamily = []string{\"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\"}\n\tcase \"mimo-api\":\n\t\tfamily = []string{\"mimo-api\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "d9e43cee32729cf12844abeba13fac079ebce011": {"t": 1785750436.7379797, "n": 173206, "h": "49afbd98428fa408", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. Keep it stable across releases so launcher/Dock/taskbar\n// reopen behavior remains predictable on every platform.\nconst singleInstanceID = \"com.reasonix.desktop\"\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil && !t.ReadOnly {\n\t\t\t_ = t.Ctrl.Snapshot()\n\t\t}\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = t.Ctrl.Snapshot()\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl *control.Controller) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\n// snapshotTab persists the current session if the tab is writable.\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl *control.Controller, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl *control.Controller, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl *control.Controller) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl *control.Controller\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl *control.Controller\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"` // provider: \"discord\"|\"weixin\"|\"telegram\"|etc.\n\tChannelLabel string `json:\"channelLabel,omitempty\"` // display name\n\tRemoteID string `json:\"remoteId,omitempty\"` // bot session id\n\tChatType string `json:\"chatType,omitempty\"` // \"dm\"|\"group\"\n\tUserID string `json:\"userId,omitempty\"` // discord user id / wx openid\n\tThreadID string `json:\"threadId,omitempty\"` // discord thread / wx group\n\tSessionSource string `json:\"sessionSource,omitempty\"` // \"auto\"|\"manual\"\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\n// channelSessionRoute maps bot session-mapping metadata to a transcript file.\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\n// botSessionPathTarget extracts a session file path from a bot session-mapping reference.\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\n// channelDisplayName returns a human-readable label for a bot channel.\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// channelSessionRoutesForDir reads the user config and returns a map from\n// session-runtime key → channel metadata for every auto-mapped bot session.\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\n// applyChannelSessionRoute sets channel metadata on a session meta from a route.\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc controllerSessionDir(ctrl *control.Controller) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tif waitDestroyHandles(destroys) {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl *control.Controller\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl *control.Controller) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\tseen := map[*control.Controller]bool{}\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.tab != nil && !releasedTabs[item.tab] {\n\t\t\treleasedTabs[item.tab] = true\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.Close()\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime) {\n\tseen := map[*control.Controller]bool{}\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.CloseAfterDestroy()\n\t}\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = ctrl.Snapshot() // persist the current session before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = HistoryToolCall{ID: tc.ID, Name: tc.Name, Arguments: args}\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, HistoryToolCall{ID: id, Name: name, Arguments: args})\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: firstNonEmpty(rec.Output, rec.Content),\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl *control.Controller\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl *control.Controller, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl *control.Controller\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl *control.Controller, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl *control.Controller, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl *control.Controller, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl *control.Controller, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl *control.Controller) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = tab.Ctrl.Snapshot()\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = tab.Ctrl.Snapshot()\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = oldCtrl.Snapshot()\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\tcase \"mimo-token-plan\":\n\t\tfamily = []string{\"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\"}\n\tcase \"mimo-api\":\n\t\tfamily = []string{\"mimo-api\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl *control.Controller, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl *control.Controller, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl *control.Controller, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\n// tabReadOnly reports whether a tab is open in read-only mode (e.g. a channel transcript).\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc (a *App) saveDocForCtrl(ctrl *control.Controller, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "4cf8164ffb16f11afa29091d644b8b1a710eb7ea": {"t": 1785750438.321986, "n": 179656, "h": "de4d7560699e0703", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. Keep it stable across releases so launcher/Dock/taskbar\n// reopen behavior remains predictable on every platform.\nconst singleInstanceID = \"com.reasonix.desktop\"\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.ReadOnly = entry.ReadOnly\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\t_ = a.snapshotTab(t)\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = a.snapshotTab(t)\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl *control.Controller) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif a.tabReadOnly(tabID) {\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc readOnlyChannelErr() error {\n\treturn fmt.Errorf(\"channel session is read-only\")\n}\n\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl *control.Controller, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl *control.Controller, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl *control.Controller) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl *control.Controller\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl *control.Controller\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tif sourceTab.ReadOnly {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, readOnlyChannelErr()\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif tab != nil && tab.ReadOnly {\n\t\treturn readOnlyChannelErr()\n\t}\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"`\n\tChannelLabel string `json:\"channelLabel,omitempty\"`\n\tRemoteID string `json:\"remoteId,omitempty\"`\n\tChatType string `json:\"chatType,omitempty\"`\n\tUserID string `json:\"userId,omitempty\"`\n\tThreadID string `json:\"threadId,omitempty\"`\n\tSessionSource string `json:\"sessionSource,omitempty\"`\n}\n\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\nfunc controllerSessionDir(ctrl *control.Controller) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tif waitDestroyHandles(destroys) {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl *control.Controller\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n\treadOnly bool\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t\treadOnly: tab.ReadOnly,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif item.readOnly {\n\t\t\tcontinue\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl *control.Controller) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\tseen := map[*control.Controller]bool{}\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.tab != nil && !releasedTabs[item.tab] {\n\t\t\treleasedTabs[item.tab] = true\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.Close()\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime) {\n\tseen := map[*control.Controller]bool{}\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.CloseAfterDestroy()\n\t}\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\ta.setTabReadOnly(tab.ID, false)\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\ta.setTabReadOnly(tab.ID, false)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = a.snapshotTab(tab) // persist writable sessions before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tSubject string `json:\"subject,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tDiff string `json:\"diff,omitempty\"`\n\tAdded int `json:\"added,omitempty\"`\n\tRemoved int `json:\"removed,omitempty\"`\n\tArgumentsArchived bool `json:\"argumentsArchived,omitempty\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\ttoolResults := historyToolResultsByID(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t\thm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != \"\")\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nconst historyToolPreviewLimit = 2_000\n\nfunc historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {\n\tcall := HistoryToolCall{\n\t\tID: tc.ID,\n\t\tName: tc.Name,\n\t\tSubject: historyToolSubject(tc.Name, args),\n\t\tSummary: historyToolSummary(tc.Name, args, result.Content),\n\t\tDiff: tc.Diff,\n\t\tAdded: tc.Added,\n\t\tRemoved: tc.Removed,\n\t}\n\tif tc.Name == \"todo_write\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif tc.ID == \"\" {\n\t\tcall.Arguments = args\n\t\treturn call\n\t}\n\tif args != \"\" {\n\t\tcall.ArgumentsArchived = true\n\t}\n\treturn call\n}\n\nfunc historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {\n\tout := map[string]provider.Message{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout[msg.ToolCallID] = msg\n\t}\n\treturn out\n}\n\nfunc historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {\n\tif content == \"\" {\n\t\treturn \"\", false, \"\"\n\t}\n\tif !canArchive {\n\t\tif historyToolResultFailed(content) {\n\t\t\treturn content, false, content\n\t\t}\n\t\treturn content, false, \"\"\n\t}\n\tif historyToolResultFailed(content) {\n\t\tdisplay = clipHistoryToolPreview(strings.TrimSpace(content))\n\t\treturn display, display != content, display\n\t}\n\treturn \"\", true, \"\"\n}\n\nfunc clipHistoryToolPreview(s string) string {\n\tif len(s) <= historyToolPreviewLimit {\n\t\treturn s\n\t}\n\treturn strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + \"\\n...\"\n}\n\nfunc historyToolSubject(name, args string) string {\n\ta := parseHistoryToolArgs(args)\n\tvar subject string\n\tswitch name {\n\tcase \"bash\":\n\t\tsubject = historyArgString(a, \"command\")\n\tcase \"grep\", \"glob\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"pattern\"), historyArgString(a, \"path\"))\n\tcase \"web_fetch\":\n\t\tsubject = historyArgString(a, \"url\")\n\tcase \"task\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"description\"), historyArgString(a, \"prompt\"))\n\tcase \"move_file\":\n\t\tsrc := historyArgString(a, \"source_path\")\n\t\tdst := historyArgString(a, \"destination_path\")\n\t\tif src != \"\" && dst != \"\" {\n\t\t\tsubject = src + \" -> \" + dst\n\t\t} else {\n\t\t\tsubject = firstNonEmpty(src, dst)\n\t\t}\n\tcase \"remember\":\n\t\tsubject = firstNonEmpty(historyArgString(a, \"name\"), historyArgString(a, \"description\"))\n\tcase \"todo_write\", \"exit_plan_mode\":\n\t\tsubject = \"\"\n\tdefault:\n\t\tsubject = firstNonEmpty(historyArgString(a, \"path\"), historyArgString(a, \"file_path\"))\n\t}\n\treturn clipSingleLine(subject, 240)\n}\n\nfunc historyToolSummary(name, args, output string) string {\n\tif historyToolResultFailed(output) {\n\t\treturn \"\"\n\t}\n\ta := parseHistoryToolArgs(args)\n\tswitch name {\n\tcase \"write_file\":\n\t\tif content := historyArgString(a, \"content\"); content != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(content))\n\t\t}\n\tcase \"edit_file\":\n\t\toldText := historyArgString(a, \"old_string\")\n\t\tnewText := historyArgString(a, \"new_string\")\n\t\tif oldText != \"\" || newText != \"\" {\n\t\t\treturn fmt.Sprintf(\"%d -> %d lines\", historyLineCount(oldText), historyLineCount(newText))\n\t\t}\n\tcase \"multi_edit\":\n\t\tif edits, ok := a[\"edits\"].([]any); ok && len(edits) > 0 {\n\t\t\treturn fmt.Sprintf(\"%d edits\", len(edits))\n\t\t}\n\t}\n\tif output == \"\" {\n\t\treturn \"\"\n\t}\n\tswitch name {\n\tcase \"read_file\":\n\t\tif strings.HasPrefix(output, \"(empty file)\") {\n\t\t\treturn \"empty file\"\n\t\t}\n\t\tif arrows := strings.Count(output, \"→\"); arrows > 0 {\n\t\t\treturn fmt.Sprintf(\"%d lines\", arrows)\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tcase \"grep\":\n\t\treturn fmt.Sprintf(\"%d matches\", historyNonEmptyLineCount(output))\n\tcase \"glob\":\n\t\treturn fmt.Sprintf(\"%d files\", historyNonEmptyLineCount(output))\n\tcase \"ls\":\n\t\treturn fmt.Sprintf(\"%d entries\", historyNonEmptyLineCount(output))\n\tcase \"web_fetch\":\n\t\treturn clipSingleLine(strings.SplitN(output, \"\\n\", 2)[0], 80)\n\tcase \"bash\":\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"no output\"\n\t\t}\n\t\treturn fmt.Sprintf(\"%d lines\", historyLineCount(output))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc parseHistoryToolArgs(args string) map[string]any {\n\tif args == \"\" {\n\t\treturn map[string]any{}\n\t}\n\tvar out map[string]any\n\tif err := json.Unmarshal([]byte(args), &out); err != nil {\n\t\treturn map[string]any{}\n\t}\n\treturn out\n}\n\nfunc historyArgString(args map[string]any, key string) string {\n\tif v, ok := args[key].(string); ok {\n\t\treturn v\n\t}\n\treturn \"\"\n}\n\nfunc historyLineCount(s string) int {\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\ts = strings.TrimSuffix(s, \"\\n\")\n\tif s == \"\" {\n\t\treturn 0\n\t}\n\treturn strings.Count(s, \"\\n\") + 1\n}\n\nfunc historyNonEmptyLineCount(s string) int {\n\tcount := 0\n\tfor _, line := range strings.Split(s, \"\\n\") {\n\t\tif strings.TrimSpace(line) != \"\" {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc clipSingleLine(s string, max int) string {\n\ts = strings.Join(strings.Fields(strings.TrimSpace(s)), \" \")\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tif max <= 3 {\n\t\treturn clipStringBytes(s, max)\n\t}\n\treturn clipStringBytes(s, max-3) + \"...\"\n}\n\nfunc clipStringBytes(s string, max int) string {\n\tif max <= 0 {\n\t\treturn \"\"\n\t}\n\tif len(s) <= max {\n\t\treturn s\n\t}\n\tfor max > 0 && !utf8.RuneStart(s[max]) {\n\t\tmax--\n\t}\n\treturn s[:max]\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tcontent := firstNonEmpty(rec.Output, rec.Content)\n\t\t\tdisplay, archived, errPreview := historyToolResultContent(content, callID != \"\")\n\t\t\tif len(out) > 0 && callID != \"\" {\n\t\t\t\tupdateHistoryToolCallSummary(out, callID, content)\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: display,\n\t\t\t\tToolResultArchived: archived,\n\t\t\t\tToolResultError: errPreview,\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {\n\tif callID == \"\" {\n\t\treturn\n\t}\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tfor j := range out[i].ToolCalls {\n\t\t\tcall := &out[i].ToolCalls[j]\n\t\t\tif call.ID != callID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif call.Summary == \"\" {\n\t\t\t\tcall.Summary = historyToolSummary(call.Name, call.Arguments, output)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl *control.Controller\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl *control.Controller, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl *control.Controller\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl *control.Controller, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl *control.Controller, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl *control.Controller, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl *control.Controller, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl *control.Controller) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\tcase \"mimo-token-plan\":\n\t\tfamily = []string{\"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\"}\n\tcase \"mimo-api\":\n\t\tfamily = []string{\"mimo-api\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl *control.Controller, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl *control.Controller, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl *control.Controller, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\nfunc (a *App) saveDocForCtrl(ctrl *control.Controller, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}, "3b86bf355ad833a51086a7e0210c746c4323b595": {"t": 1785750439.892023, "n": 173178, "h": "e986394e027ea899", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\tgoruntime \"runtime\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/billing\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/evidence\"\n\t\"reasonix/internal/fileref\"\n\tfileenc \"reasonix/internal/fileutil/encoding\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/jobs\"\n\t\"reasonix/internal/mcpdiag\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/skill\"\n)\n\n// eventChannel is the Wails runtime event name the frontend subscribes to for the\n// agent's typed event stream. One channel carries every event kind; the payload's\n// `kind` field discriminates — the desktop analogue of the serve transport's SSE\n// `data:` frames.\nconst eventChannel = \"agent:event\"\n\n// singleInstanceID is used by Wails to route a second desktop launch back to the\n// running instance. Keep it stable across releases so launcher/Dock/taskbar\n// reopen behavior remains predictable on every platform.\nconst singleInstanceID = \"com.reasonix.desktop\"\n\n// PromptHistoryEntry is one user prompt extracted from a session JSONL file.\n// The frontend uses these for ↑/↓ prompt-history navigation.\ntype PromptHistoryEntry struct {\n\tText string `json:\"text\"`\n\tAt int64 `json:\"at\"` // unix ms\n\tSessionPath string `json:\"sessionPath\"`\n\tTurn int `json:\"turn\"`\n}\n\n// PromptHistoryResult is returned as one Wails value. It carries one loaded tape\n// segment plus the cursor needed to keep walking toward older prompts.\ntype PromptHistoryResult struct {\n\tEntries []PromptHistoryEntry `json:\"entries\"`\n\tNonce string `json:\"nonce\"`\n\tOlderCursor string `json:\"olderCursor,omitempty\"`\n\tHasOlder bool `json:\"hasOlder\"`\n}\n\n// App is the Wails-bound application object: the desktop frontend's command\n// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS\n// bindings. The app manages multiple WorkspaceTabs — each with its own controller\n// scoped to a project workspace — and routes commands to the active tab. Events\n// flow the other way: each tab's controller emits to a tabEventSink that\n// forwards events tagged with tabId to the webview via runtime.EventsEmit.\ntype App struct {\n\tctx context.Context\n\n\t// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read\n\t// from bound methods. All bound methods that touch a controller use activeCtrl().\n\tmu sync.RWMutex\n\ttabs map[string]*WorkspaceTab\n\ttabOrder []string\n\tactiveTabID string\n\treadyHook func()\n\tprojectTreeChangedHook func()\n\n\t// detachedSessions keeps live session runtimes whose visible tab was closed.\n\t// It is process-local by design: shutdown closes every detached controller.\n\tdetachedSessions map[string]*WorkspaceTab\n\n\t// sharedHosts holds one *plugin.Host per workspace root, shared by all\n\t// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are\n\t// spawned once instead of N times. Lifecycle: first Acquire creates the\n\t// host, last Release closes it.\n\tsharedHosts map[string]*sharedPluginHost\n\tsharedHostsMu sync.Mutex\n\n\t// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.\n\ttabsSaveMu sync.Mutex\n\ttabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot\n\ttabsLastWrittenVersion uint64 // protected by tabsSaveMu\n\n\tforceQuit atomic.Bool\n\tbackgroundMaximised atomic.Bool\n\ttrayReady bool\n\ttray *desktopTray\n\n\tmediaTokens *mediaTokenStore\n\tbotInstalls map[string]*botInstallSession\n\tbotRuntime *desktopBotRuntime\n\n\tmetrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics\n\n\truntimeEvents asyncRuntimeEmitter\n\n\t// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It\n\t// stores session order and per-session parsed entries only after that session is\n\t// reached by ↑ navigation. See ScanPromptHistory.\n\tpromptHistoryMu sync.Mutex\n\tpromptHistoryTape *promptHistoryTape\n\n\tskillRootsMu sync.Mutex\n\tskillRootsCache skillRootsCache\n\n\theartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup\n}\n\ntype skillRootsCache struct {\n\tkey string\n\tat time.Time\n\troots []SkillRootView\n}\n\n// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.\ntype mediaTokenEntry struct {\n\tabsPath string\n\tfilename string\n\tmime string\n\tkind string\n\tsize int64\n\tmodTime time.Time\n\tcreatedAt time.Time\n\texpiresAt time.Time\n}\n\n// mediaTokenStore manages temporary tokens that grant access to workspace files\n// through the AssetServer middleware. Tokens expire after a fixed TTL and are\n// capped at a maximum count; creating a new token evicts the oldest entry when\n// the store is full.\ntype mediaTokenStore struct {\n\tmu sync.Mutex\n\tbyTok map[string]*mediaTokenEntry\n\torder []string // oldest first\n\tmaxN int\n\tttl time.Duration\n}\n\nconst mediaTokenMax = 256\n\nfunc newMediaTokenStore() *mediaTokenStore {\n\treturn &mediaTokenStore{\n\t\tbyTok: map[string]*mediaTokenEntry{},\n\t\tmaxN: mediaTokenMax,\n\t\tttl: 10 * time.Minute,\n\t}\n}\n\nfunc (s *mediaTokenStore) cleanupLocked() {\n\tnow := time.Now()\n\tfor len(s.order) > 0 {\n\t\ttok := s.order[0]\n\t\te := s.byTok[tok]\n\t\tif e == nil {\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tif !now.Before(e.expiresAt) {\n\t\t\tdelete(s.byTok, tok)\n\t\t\ts.order = s.order[1:]\n\t\t\tcontinue\n\t\t}\n\t\tbreak\n\t}\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n}\n\nfunc (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.cleanupLocked()\n\n\ttok := make([]byte, 16)\n\tif _, err := rand.Read(tok); err != nil {\n\t\tpanic(\"crypto/rand.Read failed: \" + err.Error())\n\t}\n\ttoken := hex.EncodeToString(tok)\n\n\tnow := time.Now()\n\ts.byTok[token] = &mediaTokenEntry{\n\t\tabsPath: absPath,\n\t\tfilename: filename,\n\t\tmime: mime,\n\t\tkind: kind,\n\t\tsize: size,\n\t\tmodTime: modTime,\n\t\tcreatedAt: now,\n\t\texpiresAt: now.Add(s.ttl),\n\t}\n\ts.order = append(s.order, token)\n\n\t// Trim oldest if the new token pushed us over the limit.\n\tfor len(s.order) > s.maxN {\n\t\toldest := s.order[0]\n\t\tdelete(s.byTok, oldest)\n\t\ts.order = s.order[1:]\n\t}\n\n\treturn token\n}\n\nfunc (s *mediaTokenStore) get(token string) *mediaTokenEntry {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\te := s.byTok[token]\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif time.Now().After(e.expiresAt) {\n\t\tdelete(s.byTok, token)\n\t\treturn nil\n\t}\n\treturn e\n}\n\nfunc (a *App) ensureMediaTokenStore() *mediaTokenStore {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tif a.mediaTokens == nil {\n\t\ta.mediaTokens = newMediaTokenStore()\n\t}\n\treturn a.mediaTokens\n}\n\n// workspaceMediaMiddleware returns an HTTP middleware that intercepts\n// /__reasonix_workspace_media/{token}/{filename} requests and serves the\n// corresponding workspace file. All other paths pass through to the Wails\n// default asset handler unchanged.\nfunc (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tprefix := \"/__reasonix_workspace_media/\"\n\t\t\tif !strings.HasPrefix(r.URL.Path, prefix) {\n\t\t\t\tnext.ServeHTTP(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif r.Method != http.MethodGet && r.Method != http.MethodHead {\n\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trest := strings.TrimPrefix(r.URL.Path, prefix)\n\t\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttoken := parts[0]\n\n\t\t\tentry := a.ensureMediaTokenStore().get(token)\n\t\t\tif entry == nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tf, err := os.Open(entry.absPath)\n\t\t\tif err != nil {\n\t\t\t\thttp.NotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer f.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", entry.mime)\n\t\t\tw.Header().Set(\"Content-Disposition\", mime.FormatMediaType(\"inline\", map[string]string{\"filename\": entry.filename}))\n\t\t\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\t\tw.Header().Set(\"Cache-Control\", \"private, max-age=600\")\n\t\t\thttp.ServeContent(w, r, entry.filename, entry.modTime, f)\n\t\t})\n\t}\n}\n\n// NewApp constructs the bound object. Tabs are restored in startup from the\n// last session's desktop-tabs.json.\nfunc NewApp() *App {\n\treturn &App{\n\t\ttabs: map[string]*WorkspaceTab{},\n\t\tdetachedSessions: map[string]*WorkspaceTab{},\n\t\tmediaTokens: newMediaTokenStore(),\n\t\tbotInstalls: map[string]*botInstallSession{},\n\t\tbotRuntime: newDesktopBotRuntime(),\n\t}\n}\n\nfunc (a *App) bootContext() context.Context {\n\tif a.ctx != nil {\n\t\treturn a.ctx\n\t}\n\treturn context.Background()\n}\n\n// Platform exposes the native OS to the frontend so chrome/layout affordances can\n// stay platform-scoped instead of relying on browser user-agent guesses.\nfunc (a *App) Platform() string {\n\treturn goruntime.GOOS\n}\n\n// startup runs once the webview process is up, before the frontend can issue any\n// bound call. It captures the Wails context (needed for EventsEmit), then kicks\n// off the initialization in a background goroutine so the webview loads immediately.\nfunc (a *App) startup(ctx context.Context) {\n\ta.ctx = ctx\n\tinstallSystemQuitHook()\n\ta.startTray()\n\n\tif cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != \"dev\" {\n\t\ta.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))\n\t\ta.recordSettingsMetricsSnapshot(cfg)\n\t}\n\n\ta.heartbeat = newHeartbeatEngine(a)\n\ta.heartbeat.Start()\n\n\tgo a.restoreOrBuildTabs()\n\ta.goSafe(\"refreshBotRuntime\", a.refreshBotRuntime)\n\ta.goSafe(\"sendStartupPing\", a.sendStartupPing)\n\ta.goSafe(\"flushMetrics\", a.flushMetrics)\n\ta.goSafe(\"flushPendingCrash\", a.flushPendingCrash)\n}\n\nfunc (a *App) beforeClose(ctx context.Context) bool {\n\tif a.forceQuit.Swap(false) || consumeSystemQuitRequested() {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\tcfg = config.LoadForEdit(config.UserConfigPath())\n\t}\n\tif cfg.DesktopCloseBehavior() == \"background\" {\n\t\ta.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))\n\t\ta.saveWindowStateSync()\n\t\ta.snapshotAllTabs()\n\t\thideForBackground(ctx)\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a *App) showMainWindow() {\n\tif a.ctx != nil {\n\t\tshowFromBackground(a.ctx, a.backgroundMaximised.Swap(false))\n\t}\n}\n\nfunc (a *App) secondInstanceLaunch() {\n\ta.showMainWindow()\n}\n\nfunc (a *App) quitApp() {\n\tif a.ctx == nil {\n\t\treturn\n\t}\n\ta.forceQuit.Store(true)\n\truntime.Quit(a.ctx)\n}\n\nfunc hideForBackground(ctx context.Context) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Hide(ctx)\n\t\treturn\n\t}\n\truntime.WindowHide(ctx)\n}\n\nfunc showFromBackground(ctx context.Context, wasMaximised bool) {\n\tif backgroundCloseUsesApplicationHide(goruntime.GOOS) {\n\t\truntime.Show(ctx)\n\t}\n\tplan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)\n\tif plan.maximiseBeforeShow {\n\t\truntime.WindowMaximise(ctx)\n\t}\n\truntime.WindowShow(ctx)\n\tif plan.unminimiseAfterShow {\n\t\truntime.WindowUnminimise(ctx)\n\t}\n}\n\nfunc backgroundCloseUsesApplicationHide(goos string) bool {\n\treturn goos == \"darwin\"\n}\n\ntype backgroundRestorePlan struct {\n\tmaximiseBeforeShow bool\n\tunminimiseAfterShow bool\n}\n\nfunc backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {\n\tif backgroundRestoreShouldMaximise(goos, wasMaximised) {\n\t\treturn backgroundRestorePlan{maximiseBeforeShow: true}\n\t}\n\treturn backgroundRestorePlan{unminimiseAfterShow: true}\n}\n\nfunc backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {\n\treturn wasMaximised && !backgroundCloseUsesApplicationHide(goos)\n}\n\n// restoreOrBuildTabs restores the tabs from the last session, or creates a\n// default Global tab on first launch.\nfunc (a *App) restoreOrBuildTabs() {\n\tdefer a.recoverToPending(\"restoreOrBuildTabs\")\n\t// Reap any orphaned codegraph processes from a previous crash or older\n\t// version that leaked them, so they don't accumulate across restarts.\n\ta.reapOrphanCodeGraph()\n\tctx := a.ctx\n\tensureWorkspace()\n\n\t// Run legacy config migration before the first config load so the\n\t// freshly written config (including the user's default_model) is\n\t// picked up by Load instead of falling back to built-in defaults.\n\t_, _ = config.MigrateLegacyIfNeeded()\n\tf := loadTabsFile()\n\t_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())\n\t_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))\n\n\t// Load i18n from the first available config.\n\t// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),\n\t// so the user's language choice in desktop settings takes effect.\n\tif cfg, err := config.Load(); err == nil {\n\t\tlang := cfg.DesktopLanguage()\n\t\tif lang == \"\" {\n\t\t\tlang = cfg.Language\n\t\t}\n\t\ti18n.DetectLanguage(lang)\n\t}\n\n\tif len(f.Tabs) > 0 {\n\t\ttoBuild := make([]*WorkspaceTab, 0, len(f.Tabs))\n\t\tfor _, entry := range f.Tabs {\n\t\t\ta.mu.Lock()\n\t\t\tid := a.restoredTabIDLocked(entry.ID)\n\t\t\ta.mu.Unlock()\n\n\t\t\tvar tab *WorkspaceTab\n\t\t\tif entry.Scope == \"project\" {\n\t\t\t\ttab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)\n\t\t\t} else {\n\t\t\t\ttab = a.createTabEntryWithID(\"global\", globalTabWorkspaceRoot(), entry.TopicID, id)\n\t\t\t}\n\t\t\ttab.model = entry.Model\n\t\t\ttab.effort = cloneStringPtr(entry.Effort)\n\t\t\ttab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)\n\t\t\ttab.mode = persistedTabMode(entry.Mode)\n\t\t\ttab.goal = strings.TrimSpace(entry.Goal)\n\t\t\ttab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)\n\t\t\tif tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {\n\t\t\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t\t\t}\n\t\t\ttab.SessionPath = strings.TrimSpace(entry.SessionPath)\n\t\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\t\t\ta.mu.Lock()\n\t\t\ta.tabs[tab.ID] = tab\n\t\t\ta.tabOrder = append(a.tabOrder, tab.ID)\n\t\t\ta.mu.Unlock()\n\t\t\ttoBuild = append(toBuild, tab)\n\t\t}\n\t\ta.mu.Lock()\n\t\tif _, ok := a.tabs[f.ActiveTab]; ok {\n\t\t\ta.activeTabID = f.ActiveTab\n\t\t} else {\n\t\t\tordered := a.orderedTabIDsLocked()\n\t\t\tif len(ordered) > 0 {\n\t\t\t\ta.activeTabID = ordered[0]\n\t\t\t}\n\t\t}\n\t\ta.mu.Unlock()\n\t\tfor _, tab := range toBuild {\n\t\t\ta.startTabControllerBuild(tab)\n\t\t}\n\t\treturn\n\t}\n\n\t// First launch: create a default Global tab.\n\ttab := a.createTabEntry(\"global\", globalTabWorkspaceRoot(), \"\")\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}\n\ttab.TopicTitle = \"Global\"\n\ta.mu.Lock()\n\ta.tabs[tab.ID] = tab\n\ta.tabOrder = append(a.tabOrder, tab.ID)\n\ta.activeTabID = tab.ID\n\ta.mu.Unlock()\n\ta.startTabControllerBuild(tab)\n}\n\nfunc (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {\n\treturn a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())\n}\n\nfunc (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {\n\treturn &WorkspaceTab{\n\t\tID: id,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),\n\t\ttokenMode: boot.TokenModeFull,\n\t\tmode: \"normal\",\n\t\ttoolApprovalMode: control.ToolApprovalAsk,\n\t\tdisabledMCP: map[string]ServerView{},\n\t}\n}\n\nfunc (a *App) snapshotAllTabs() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil && !t.ReadOnly {\n\t\t\t_ = t.Ctrl.Snapshot()\n\t\t}\n\t}\n}\n\n// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.\nfunc (a *App) shutdown(context.Context) {\n\tif a.heartbeat != nil {\n\t\ta.heartbeat.Stop()\n\t}\n\ta.stopBotRuntime()\n\ta.stopTray()\n\t// Save window geometry synchronously from Go so it's persisted even if the\n\t// frontend's beforeunload promise hasn't resolved yet.\n\ta.saveWindowStateSync()\n\t// Close every shared plugin host on exit, even if a tab cleanup panics.\n\tdefer a.closeAllSharedHosts()\n\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\t_ = t.Ctrl.Snapshot()\n\t\t\tt.Ctrl.Close()\n\t\t}\n\t}\n}\n\n// domReady is called (via OnDomReady) after the webview finishes loading its DOM\n// but before the window is shown (StartHidden). It restores the saved window\n// position and size, then calls WindowShow so the user never sees the default\n// size/position flash.\nfunc (a *App) domReady(_ context.Context) {\n\tstate, ok := loadWindowState()\n\tif ok {\n\t\t// Validate saved position against current screens. Wails v2 doesn't\n\t\t// expose per-screen origin (x,y offsets) so we can only do a basic\n\t\t// sanity check: ensure the window origin falls within a generous\n\t\t// estimate of the screen area. If the user unplugged an external\n\t\t// display, negative or out-of-bounds coordinates are caught here.\n\t\tvalid := state.X >= 0 && state.Y >= 0\n\t\tif valid {\n\t\t\tscreens, err := runtime.ScreenGetAll(a.ctx)\n\t\t\tif err == nil && len(screens) > 0 {\n\t\t\t\tmaxW, maxH := 0, 0\n\t\t\t\tfor _, sc := range screens {\n\t\t\t\t\tif sc.Size.Width > maxW {\n\t\t\t\t\t\tmaxW = sc.Size.Width\n\t\t\t\t\t}\n\t\t\t\t\tif sc.Size.Height > maxH {\n\t\t\t\t\t\tmaxH = sc.Size.Height\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif state.X > maxW*2 || state.Y > maxH*2 {\n\t\t\t\t\tvalid = false\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif valid {\n\t\t\truntime.WindowSetPosition(a.ctx, state.X, state.Y)\n\t\t} else {\n\t\t\truntime.WindowCenter(a.ctx)\n\t\t}\n\t} else {\n\t\truntime.WindowCenter(a.ctx)\n\t}\n\n\tif ok && state.Maximised {\n\t\truntime.WindowMaximise(a.ctx)\n\t}\n\n\truntime.WindowShow(a.ctx)\n}\n\n// --- bound command surface (frontend → controller) ---\n// Each method guards on a nil controller so a pre-startup or failed-build call is\n// a no-op, never a panic.\n\n// Submit runs raw user input as a turn; slash commands and @-references are\n// resolved by the controller. Output arrives asynchronously on eventChannel.\nfunc (a *App) Submit(input string) {\n\ta.SubmitToTab(\"\", input)\n}\n\nfunc (a *App) SubmitToTab(tabID, input string) {\n\ttrimmed := strings.TrimSpace(input)\n\tif trimmed == \"/effort\" || strings.HasPrefix(trimmed, \"/effort \") {\n\t\ta.runEffortCommandForTab(tabID, trimmed)\n\t\treturn\n\t}\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.SubmitDisplay(input, input)\n\t}\n}\n\nfunc (a *App) submitUserTurnToTab(tabID, input string) bool {\n\tif a.tabReadOnly(tabID) {\n\t\treturn false\n\t}\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tctrl.SubmitUserTurn(input, input)\n\treturn true\n}\n\n// RunShell executes a shell command directly (bypassing the model) and streams\n// output as events on eventChannel.\nfunc (a *App) RunShell(command string) {\n\ta.RunShellForTab(\"\", command)\n}\n\nfunc (a *App) RunShellForTab(tabID, command string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.RunShell(command)\n\t}\n}\n\n// SubmitDisplay runs input as a turn while recording a shorter UI-only display\n// string for the saved desktop transcript. The model still receives input.\nfunc (a *App) SubmitDisplay(display, input string) {\n\ta.SubmitDisplayToTab(\"\", display, input)\n}\n\nfunc (a *App) SubmitDisplayToTab(tabID, display, input string) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SubmitDisplay(display, input)\n}\n\nfunc (a *App) bindControllerDisplayRecorder(ctrl *control.Controller) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetDisplayRecorder(func(content, display string) {\n\t\tdir := ctrl.SessionDir()\n\t\tif dir == \"\" {\n\t\t\tdir = config.SessionDir()\n\t\t}\n\t\t_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)\n\t})\n}\n\n// Cancel aborts the in-flight turn.\nfunc (a *App) Cancel() {\n\ta.CancelTab(\"\")\n}\n\nfunc (a *App) CancelTab(tabID string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Cancel()\n\t}\n}\n\n// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.\nfunc (a *App) Steer(text string) {\n\ta.SteerForTab(\"\", text)\n}\n\n// SteerForTab sends mid-turn guidance to a specific tab's agent.\nfunc (a *App) SteerForTab(tabID, text string) {\n\tif ctrl := a.ctrlByTabID(tabID); ctrl != nil {\n\t\tctrl.Steer(text)\n\t}\n}\n\n// snapshotTab persists the current session if the tab is writable.\nfunc (a *App) snapshotTab(tab *WorkspaceTab) error {\n\tif tab == nil {\n\t\treturn nil\n\t}\n\ta.mu.RLock()\n\treadOnly := tab.ReadOnly\n\tctrl := tab.Ctrl\n\ta.mu.RUnlock()\n\tif readOnly || ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Snapshot()\n}\n\n// Approve answers a pending approval_request by ID: allow runs the call, session\n// also remembers the grant for the rest of the session.\nfunc (a *App) Approve(id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(\"\")\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ApproveTab is like Approve but scoped to a specific tab.\nfunc (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl != nil {\n\t\tctrl.Approve(id, allow, session, persist)\n\t}\n}\n\n// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask\n// prompt that is currently blocking its run loop. The frontend calls this once\n// its event subscription is live (on load/reconnect) so a session that was\n// already awaiting confirmation rebuilds its modal instead of showing a\n// \"waiting\" status with no way to answer — and no way to stop.\nfunc (a *App) ReplayPendingPrompts() {\n\ta.mu.RLock()\n\ttabs := a.runtimeTabsLocked()\n\ta.mu.RUnlock()\n\tfor _, t := range tabs {\n\t\tif t.Ctrl != nil {\n\t\t\tt.Ctrl.ReplayPendingPrompts()\n\t\t}\n\t}\n}\n\n// SetPlanMode toggles the read-only plan axis while preserving the current\n// tool-auto-approval axis.\nfunc (a *App) SetPlanMode(on bool) {\n\ta.setPlanModeForTab(\"\", on)\n}\n\nfunc (a *App) setPlanModeForTab(tabID string, on bool) {\n\tcurrent := a.currentModeForTab(tabID)\n\ta.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))\n}\n\n// SetMode applies a composer gating mode (\"plan\" | \"yolo\" | \"plan-yolo\" |\n// anything else =\n// normal) in one call, so a turn submitted right after the switch can't race a\n// half-applied plan/tool-auto-approval pair.\nfunc (a *App) SetMode(mode string) {\n\ta.SetModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetModeForTab(tabID, mode string) {\n\tnormalized := normalizeTabMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.mode = normalized\n\ttab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)\n\tif tabModeHasAutoApproveTools(normalized) {\n\t\ttab.toolApprovalMode = control.ToolApprovalYolo\n\t} else if tab.toolApprovalMode == control.ToolApprovalYolo {\n\t\ttab.toolApprovalMode = control.ToolApprovalAsk\n\t}\n\tctrl := tab.Ctrl\n\tapprovalMode := tab.toolApprovalMode\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabModeToController(ctrl, normalized)\n\tapplyTabToolApprovalModeToController(ctrl, approvalMode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc applyTabModeToController(ctrl *control.Controller, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tswitch normalizeTabMode(mode) {\n\tcase \"plan\":\n\t\tctrl.SetMode(true, false)\n\tcase \"yolo\":\n\t\tctrl.SetMode(false, true)\n\tcase \"plan-yolo\":\n\t\tctrl.SetMode(true, true)\n\tdefault:\n\t\tctrl.SetMode(false, false)\n\t}\n}\n\nfunc applyTabToolApprovalModeToController(ctrl *control.Controller, mode string) {\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))\n}\n\nfunc (a *App) currentModeForTab(tabID string) string {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tmode := \"normal\"\n\tif tab != nil {\n\t\tmode = currentTabMode(tab)\n\t}\n\ta.mu.RUnlock()\n\treturn mode\n}\n\nfunc normalizeCollaborationMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"plan\":\n\t\treturn \"plan\"\n\tcase \"goal\":\n\t\treturn \"goal\"\n\tdefault:\n\t\treturn \"normal\"\n\t}\n}\n\nfunc (a *App) SetCollaborationMode(mode string) {\n\ta.SetCollaborationModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetCollaborationModeForTab(tabID, mode string) {\n\tmode = normalizeCollaborationMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\tapprovalMode := currentTabToolApprovalMode(tab)\n\tswitch mode {\n\tcase \"plan\":\n\t\ttab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\tcase \"goal\":\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\tdefault:\n\t\ttab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)\n\t\ttab.goal = \"\"\n\t}\n\tctrl := tab.Ctrl\n\tgoal := tab.goal\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// QuestionAnswer is the frontend's reply to one question in an ask_request.\ntype QuestionAnswer struct {\n\tQuestionID string `json:\"questionId\"`\n\tSelected []string `json:\"selected\"`\n}\n\n// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the\n// user's selections per question.\nfunc (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {\n\ta.AnswerQuestionForTab(\"\", id, answers)\n}\n\nfunc (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn\n\t}\n\tout := make([]event.AskAnswer, len(answers))\n\tfor i, an := range answers {\n\t\tout[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}\n\t}\n\tctrl.AnswerQuestion(id, out)\n}\n\n// Compact runs one compaction pass on demand.\n// Compact runs a plain compaction pass (the \"compact now\" button). Focus-guided\n// compaction goes through Submit(\"/compact \") instead.\nfunc (a *App) Compact() error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.Compact(a.ctx, \"\")\n}\n\n// workspaceNotReadyErr names why a session action arrived before the tab's\n// controller existed: still starting, or failed to start. Silently returning\n// nil here swallowed the click with no feedback (#3938).\nfunc workspaceNotReadyErr(tab *WorkspaceTab) error {\n\tif tab != nil && strings.TrimSpace(tab.StartupErr) != \"\" {\n\t\treturn fmt.Errorf(\"workspace failed to start: %s\", tab.StartupErr)\n\t}\n\treturn fmt.Errorf(\"workspace is still starting\")\n}\n\n// NewSession snapshots the current conversation and rotates to a fresh one.\nfunc (a *App) NewSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\t// Tab is already blank — just persist and skip the new-session dance.\n\tif !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {\n\t\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\t\treturn nil\n\t}\n\n\tif err := ctrl.NewSession(); err != nil {\n\t\treturn err\n\t}\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc messagesHaveConversationContent(messages []provider.Message) bool {\n\tfor _, msg := range messages {\n\t\tif msg.Role != provider.RoleSystem {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ClearSession discards the current conversation and rotates to a fresh unsaved one.\nfunc (a *App) ClearSession() error {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn workspaceNotReadyErr(tab)\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn a.clearActiveSessionRuntime(tab, ctrl)\n\t}\n\tif err := ctrl.ClearSession(); err != nil {\n\t\treturn err\n\t}\n\ttab.resetTelemetry()\n\ta.persistTabSessionPath(tab, ctrl.SessionPath())\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl *control.Controller) error {\n\tif tab == nil || oldCtrl == nil {\n\t\treturn fmt.Errorf(\"workspace is still starting\")\n\t}\n\toldPath := oldCtrl.SessionPath()\n\toldSink := tab.sink\n\tif oldSink != nil {\n\t\toldSink.tabID = detachedRuntimeTabID(oldPath)\n\t\toldSink.ctx = nil\n\t}\n\tif oldCtrl.RuntimeStatus().Cancellable {\n\t\toldCtrl.Cancel()\n\t\tif err := waitControllerStopped(oldCtrl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdestroy := oldCtrl.BeginDestroySession(oldPath)\n\tdestroys := []control.SessionDestroyHandle{destroy}\n\tteardownTimedOut := waitDestroyHandles(destroys)\n\tif teardownTimedOut {\n\t\tif err := agent.MarkCleanupPending(oldPath, \"clear\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tnewSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: tab.model,\n\t\tRequireKey: false,\n\t\tSink: newSink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\tif teardownTimedOut {\n\t\t\t// The old session was already marked cleanup-pending, so finish the\n\t\t\t// destroy cleanup instead of re-exposing a runtime in teardown.\n\t\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t\t} else {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t}\n\t\tif oldSink != nil {\n\t\t\toldSink.tabID = tab.ID\n\t\t\toldSink.ctx = a.ctx\n\t\t}\n\t\treturn err\n\t}\n\tif teardownTimedOut {\n\t\tgo delayedDesktopSessionCleanup(oldPath, destroys)\n\t} else {\n\t\tif err := removeDesktopSessionArtifacts(oldPath); err != nil {\n\t\t\tfinishDestroyHandles(destroys)\n\t\t\tnewCtrl.Close()\n\t\t\treturn err\n\t\t}\n\t\tfinishDestroyHandles(destroys)\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())\n\tnewCtrl.SetSessionPath(path)\n\n\ta.mu.Lock()\n\tif current := a.tabs[tab.ID]; current == tab {\n\t\ttab.Ctrl = newCtrl\n\t\ttab.sink = newSink\n\t\ttab.SessionPath = path\n\t\ttab.Label = newCtrl.Label()\n\t\ttab.Ready = true\n\t\ttab.StartupErr = \"\"\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n\toldCtrl.CloseAfterDestroy()\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc removeDesktopSessionArtifacts(path string) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tif err := jobs.RemoveArtifacts(path); err != nil {\n\t\treturn err\n\t}\n\tpaths := []string{path, agent.BranchMetaPath(path)}\n\tif strings.HasSuffix(path, \".jsonl\") {\n\t\tpaths = append(paths, strings.TrimSuffix(path, \".jsonl\")+\".ckpt\")\n\t}\n\tfor _, p := range paths {\n\t\tif strings.TrimSpace(p) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {\n\t\treturn err\n\t}\n\treturn agent.ClearCleanupPending(path)\n}\n\n// CheckpointMeta summarises one rewind point (a user turn) for the desktop.\ntype CheckpointMeta struct {\n\tTurn int `json:\"turn\"`\n\tPrompt string `json:\"prompt\"`\n\tFiles []string `json:\"files\"` // paths changed during the turn\n\tTime int64 `json:\"time\"` // unix milliseconds\n\tCanCode bool `json:\"canCode\"`\n\tCanConversation bool `json:\"canConversation\"`\n}\n\n// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.\nfunc (a *App) Checkpoints() []CheckpointMeta {\n\treturn a.CheckpointsForTab(\"\")\n}\n\nfunc (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {\n\ta.mu.RLock()\n\tvar ctrl *control.Controller\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn []CheckpointMeta{}\n\t}\n\tmetas := ctrl.Checkpoints()\n\tout := make([]CheckpointMeta, 0, len(metas))\n\tfor _, m := range metas {\n\t\tout = append(out, CheckpointMeta{\n\t\t\tTurn: m.Turn,\n\t\t\tPrompt: m.Prompt,\n\t\t\tFiles: m.Paths,\n\t\t\tTime: m.Time.UnixMilli(),\n\t\t\tCanCode: len(m.Paths) > 0,\n\t\t\tCanConversation: ctrl.CheckpointHasBoundary(m.Turn),\n\t\t})\n\t}\n\t// RestoreCode(turn) reverts every file touched in this turn or any later one, so\n\t// a turn can rewind code even when it changed no files itself — as long as a\n\t// later turn did. Propagate CanCode backwards over the oldest-first list.\n\t// Also propagate the cumulative unique file count so the UI shows how many\n\t// files RestoreCode would actually affect from this turn.\n\thasCodeAfter := false\n\tcodeFileSet := make(map[string]bool, len(metas)*2)\n\tfor i := len(out) - 1; i >= 0; i-- {\n\t\tif len(out[i].Files) > 0 {\n\t\t\thasCodeAfter = true\n\t\t}\n\t\tfor _, f := range out[i].Files {\n\t\t\tcodeFileSet[f] = true\n\t\t}\n\t\tout[i].CanCode = hasCodeAfter\n\t\tout[i].Files = make([]string, 0, len(codeFileSet))\n\t\tfor f := range codeFileSet {\n\t\t\tout[i].Files = append(out[i].Files, f)\n\t\t}\n\t\tsort.Strings(out[i].Files) // map iteration is unordered; keep the list stable\n\t}\n\treturn out\n}\n\n// ToolResultForTab returns the full arguments and output for one tool call that\n// were elided from the frontend's in-memory items[] for memory efficiency. The\n// caller (frontend ToolCard) loads this on demand when the user expands a\n// collapsed tool card. Returns nil when the tool ID is not found.\nfunc (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {\n\ta.mu.RLock()\n\tvar ctrl *control.Controller\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.ToolResult(toolID)\n}\n\n// Rewind restores the session to the start of turn. scope is \"code\",\n// \"conversation\", or \"both\" (anything else is treated as \"both\"). The frontend\n// re-reads History after this resolves.\nfunc (a *App) Rewind(turn int, scope string) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\ts := control.RewindBoth\n\tswitch scope {\n\tcase \"code\":\n\t\ts = control.RewindCode\n\tcase \"conversation\":\n\t\ts = control.RewindConversation\n\t}\n\treturn ctrl.Rewind(turn, s)\n}\n\n// Fork branches the conversation at the start of turn into a new session tab\n// (preserving the current tab), keeping code intact, and switches to the new tab.\nfunc (a *App) Fork(turn int) (TabMeta, error) {\n\ta.mu.RLock()\n\tsourceTab := a.activeTabLocked()\n\tctrl := a.activeCtrlLocked()\n\tif sourceTab == nil || ctrl == nil {\n\t\ta.mu.RUnlock()\n\t\treturn TabMeta{}, nil\n\t}\n\tscope := sourceTab.Scope\n\tworkspaceRoot := sourceTab.WorkspaceRoot\n\tsourceTitle := sourceTab.TopicTitle\n\tmodel := sourceTab.model\n\teffort := cloneStringPtr(sourceTab.effort)\n\tmode := currentTabMode(sourceTab)\n\ttoolApprovalMode := currentTabToolApprovalMode(sourceTab)\n\tdisabledMCP := cloneServerViewMap(sourceTab.disabledMCP)\n\tmcpOrder := append([]string(nil), sourceTab.mcpOrder...)\n\ta.mu.RUnlock()\n\n\tnewPath, err := ctrl.ForkSession(turn, \"\")\n\tif err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\ttopicID := newTopicID()\n\ttopicTitle := forkTopicTitle(sourceTitle)\n\ttitleRoot := workspaceRoot\n\tif scope == \"global\" {\n\t\ttitleRoot = \"\"\n\t}\n\tif err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tm, _ := agent.EnsureBranchMeta(newPath)\n\tm.Scope = scope\n\tm.WorkspaceRoot = workspaceRoot\n\tm.TopicID = topicID\n\tm.TopicTitle = topicTitle\n\tif err := agent.SaveBranchMeta(newPath, m); err != nil {\n\t\treturn TabMeta{}, err\n\t}\n\tinvalidateTopicSessionIndexForPath(newPath)\n\n\ta.mu.Lock()\n\ttabID := a.newUniqueTabIDLocked()\n\ttab := &WorkspaceTab{\n\t\tID: tabID,\n\t\tScope: scope,\n\t\tWorkspaceRoot: workspaceRoot,\n\t\tTopicID: topicID,\n\t\tTopicTitle: topicTitle,\n\t\tSessionPath: newPath,\n\t\tmodel: model,\n\t\teffort: effort,\n\t\tmode: mode,\n\t\ttoolApprovalMode: toolApprovalMode,\n\t\tdisabledMCP: disabledMCP,\n\t\tmcpOrder: mcpOrder,\n\t}\n\ttab.sink = &tabEventSink{tabID: tabID, app: a}\n\ta.tabs[tabID] = tab\n\ta.tabOrder = append(a.tabOrder, tabID)\n\ta.activeTabID = tabID\n\ta.saveTabsLocked()\n\tmeta := a.tabMeta(tab, true)\n\ta.mu.Unlock()\n\n\ta.emitProjectTreeChanged()\n\ta.startTabControllerBuild(tab)\n\treturn meta, nil\n}\n\n// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start\n// of turn into one summary (Claude Code's \"summarize from/up to here\"), keeping\n// code intact. The frontend re-reads History after this resolves.\nfunc (a *App) SummarizeFrom(turn int) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeFrom(a.ctx, turn)\n}\n\nfunc (a *App) SummarizeUpTo(turn int) error {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn nil\n\t}\n\treturn ctrl.SummarizeUpTo(a.ctx, turn)\n}\n\n// SessionMeta summarises one saved session for the history panel.\ntype SessionMeta struct {\n\tPath string `json:\"path\"`\n\tPreview string `json:\"preview\"` // first user message\n\tTitle string `json:\"title,omitempty\"` // user-chosen name, when set (overrides preview)\n\tTurns int `json:\"turns\"`\n\tCreatedAt int64 `json:\"createdAt\"` // unix milliseconds\n\tLastActivityAt int64 `json:\"lastActivityAt\"` // unix milliseconds\n\tModTime int64 `json:\"modTime\"` // compatibility alias for lastActivityAt\n\tDeletedAt int64 `json:\"deletedAt,omitempty\"`\n\tCurrent bool `json:\"current\"`\n\tOpen bool `json:\"open\"`\n\tScope string `json:\"scope,omitempty\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tTopicID string `json:\"topicId,omitempty\"`\n\tTopicTitle string `json:\"topicTitle,omitempty\"`\n\tKind string `json:\"kind,omitempty\"` // \"channel\" for external IM transcripts\n\tChannel string `json:\"channel,omitempty\"` // provider: \"discord\"|\"weixin\"|\"telegram\"|etc.\n\tChannelLabel string `json:\"channelLabel,omitempty\"` // display name\n\tRemoteID string `json:\"remoteId,omitempty\"` // bot session id\n\tChatType string `json:\"chatType,omitempty\"` // \"dm\"|\"group\"\n\tUserID string `json:\"userId,omitempty\"` // discord user id / wx openid\n\tThreadID string `json:\"threadId,omitempty\"` // discord thread / wx group\n\tSessionSource string `json:\"sessionSource,omitempty\"` // \"auto\"|\"manual\"\n}\n\ntype WorkspaceMeta struct {\n\tPath string `json:\"path\"`\n\tName string `json:\"name\"`\n\tCurrent bool `json:\"current\"`\n}\n\n// channelSessionRoute maps bot session-mapping metadata to a transcript file.\ntype channelSessionRoute struct {\n\tchannel string\n\tchannelLabel string\n\tremoteID string\n\tchatType string\n\tuserID string\n\tthreadID string\n\tsessionSource string\n}\n\n// botSessionPathTarget extracts a session file path from a bot session-mapping reference.\nfunc botSessionPathTarget(sessionID string) string {\n\tsessionID = strings.TrimSpace(sessionID)\n\tif sessionID == \"\" {\n\t\treturn \"\"\n\t}\n\tif strings.HasPrefix(strings.ToLower(sessionID), \"path:\") {\n\t\treturn strings.TrimSpace(sessionID[5:])\n\t}\n\tif strings.HasSuffix(sessionID, \".jsonl\") || strings.Contains(sessionID, \"/\") || strings.Contains(sessionID, `\\`) || strings.HasPrefix(sessionID, \"~\") {\n\t\treturn sessionID\n\t}\n\treturn \"\"\n}\n\n// channelDisplayName returns a human-readable label for a bot channel.\nfunc channelDisplayName(provider, domain string) string {\n\tprovider = strings.TrimSpace(provider)\n\tdomain = strings.TrimSpace(domain)\n\tswitch provider {\n\tcase \"feishu\":\n\t\tif strings.EqualFold(domain, \"lark\") {\n\t\t\treturn \"Lark\"\n\t\t}\n\t\treturn \"Feishu\"\n\tcase \"weixin\":\n\t\treturn \"WeChat\"\n\tcase \"qq\":\n\t\treturn \"QQ\"\n\tdefault:\n\t\treturn provider\n\t}\n}\n\n// channelSessionRoutesForDir reads the user config and returns a map from\n// session-runtime key → channel metadata for every auto-mapped bot session.\nfunc channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {\n\tuserPath := config.UserConfigPath()\n\tif strings.TrimSpace(userPath) == \"\" {\n\t\treturn nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tout := map[string]channelSessionRoute{}\n\tfor _, conn := range cfg.Bot.Connections {\n\t\tchannel := strings.TrimSpace(conn.Provider)\n\t\tif channel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tchannelLabel := strings.TrimSpace(conn.Label)\n\t\tif channelLabel == \"\" {\n\t\t\tchannelLabel = channelDisplayName(channel, conn.Domain)\n\t\t}\n\t\tfor _, mapping := range conn.SessionMappings {\n\t\t\tif strings.TrimSpace(mapping.SessionSource) != \"auto\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsessionPath := botSessionPathTarget(mapping.SessionID)\n\t\t\tif sessionPath == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalidPath, _, err := validateSessionPath(dir, sessionPath)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := sessionRuntimeKey(validPath)\n\t\t\tif key == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout[key] = channelSessionRoute{\n\t\t\t\tchannel: channel,\n\t\t\t\tchannelLabel: channelLabel,\n\t\t\t\tremoteID: strings.TrimSpace(mapping.RemoteID),\n\t\t\t\tchatType: strings.TrimSpace(mapping.ChatType),\n\t\t\t\tuserID: strings.TrimSpace(mapping.UserID),\n\t\t\t\tthreadID: strings.TrimSpace(mapping.ThreadID),\n\t\t\t\tsessionSource: strings.TrimSpace(mapping.SessionSource),\n\t\t\t}\n\t\t}\n\t}\n\tif len(out) == 0 {\n\t\treturn nil\n\t}\n\treturn out\n}\n\n// applyChannelSessionRoute sets channel metadata on a session meta from a route.\nfunc applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {\n\tif meta == nil {\n\t\treturn\n\t}\n\tmeta.Kind = \"channel\"\n\tmeta.Channel = route.channel\n\tmeta.ChannelLabel = route.channelLabel\n\tmeta.RemoteID = route.remoteID\n\tmeta.ChatType = route.chatType\n\tmeta.UserID = route.userID\n\tmeta.ThreadID = route.threadID\n\tmeta.SessionSource = route.sessionSource\n}\n\nfunc controllerSessionDir(ctrl *control.Controller) string {\n\tif ctrl != nil {\n\t\tif dir := ctrl.SessionDir(); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc tabSessionDir(tab *WorkspaceTab) string {\n\tif tab != nil {\n\t\tif tab.Ctrl != nil {\n\t\t\tif dir := tab.Ctrl.SessionDir(); dir != \"\" {\n\t\t\t\treturn dir\n\t\t\t}\n\t\t}\n\t\tif tab.WorkspaceRoot != \"\" {\n\t\t\treturn desktopSessionDir(tab.WorkspaceRoot)\n\t\t}\n\t}\n\treturn desktopSessionDir(\"\")\n}\n\nfunc (a *App) activeSessionDir() string {\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tdir := tabSessionDir(tab)\n\ta.mu.RUnlock()\n\treturn dir\n}\n\n// ListSessions returns the saved sessions newest-first for the history panel,\n// marking the one the current conversation is writing to and attaching any\n// user-chosen titles.\nfunc (a *App) ListSessions() []SessionMeta {\n\tdir := a.activeSessionDir()\n\tinfos, err := agent.ListSessions(dir)\n\tif err != nil {\n\t\treturn []SessionMeta{}\n\t}\n\ttitles := loadSessionTitles(dir)\n\tchannelRoutes := channelSessionRoutesForDir(dir)\n\topen := a.openSessionPaths(dir)\n\tactive := a.activeSessionPath(dir)\n\tout := make([]SessionMeta, 0, len(infos))\n\tfor _, s := range infos {\n\t\t_, isOpen := open[s.Path]\n\t\tmeta := sessionMetaFromInfo(s, titles[filepath.Base(s.Path)], s.Path == active, isOpen, 0)\n\t\tif route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {\n\t\t\tapplyChannelSessionRoute(&meta, route)\n\t\t}\n\t\tout = append(out, meta)\n\t}\n\treturn out\n}\n\n// ListTrashedSessions returns sessions that were moved to the local trash,\n// newest-deleted first. These can be previewed, restored, or permanently purged.\nfunc (a *App) ListTrashedSessions() []SessionMeta {\n\tout := []SessionMeta{}\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tpaths, err := listTrashedSessionFiles(dir)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttitles := loadSessionTitles(dir)\n\t\tfor _, path := range paths {\n\t\t\tinfos, err := agent.ListSessions(filepath.Dir(path))\n\t\t\tif err != nil || len(infos) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeletedAt := trashedSessionDeletedAt(path)\n\t\t\tout = append(out, sessionMetaFromInfo(infos[0], titles[filepath.Base(path)], false, false, deletedAt))\n\t\t}\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\tif out[i].DeletedAt == out[j].DeletedAt {\n\t\t\treturn out[i].LastActivityAt > out[j].LastActivityAt\n\t\t}\n\t\treturn out[i].DeletedAt > out[j].DeletedAt\n\t})\n\treturn out\n}\n\nfunc (a *App) trashedSessionDir(path string) (string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tif _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {\n\t\t\treturn dir, nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"trashed session path outside known session dirs: %s\", path)\n}\n\nfunc (a *App) sessionDirForPath(path string) (string, string, error) {\n\tfor _, dir := range a.knownSessionDirs() {\n\t\tsessionPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\treturn dir, sessionPath, nil\n\t\t}\n\t}\n\treturn \"\", \"\", fmt.Errorf(\"session path outside known session dirs: %s\", path)\n}\n\nfunc sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64) SessionMeta {\n\treturn SessionMeta{\n\t\tPath: s.Path,\n\t\tPreview: s.Preview,\n\t\tTitle: title,\n\t\tTurns: s.Turns,\n\t\tCreatedAt: s.CreatedAt.UnixMilli(),\n\t\tLastActivityAt: s.LastActivityAt.UnixMilli(),\n\t\tModTime: s.LastActivityAt.UnixMilli(),\n\t\tDeletedAt: deletedAt,\n\t\tCurrent: current,\n\t\tOpen: open,\n\t\tScope: s.Scope,\n\t\tWorkspaceRoot: s.WorkspaceRoot,\n\t\tTopicID: s.TopicID,\n\t\tTopicTitle: s.TopicTitle,\n\t}\n}\n\n// DeleteSession moves a saved session to the local trash. If the session still\n// has an in-process runtime, the runtime is cancelled and removed first so\n// autosave cannot recreate or append to the deleted file later.\nfunc (a *App) DeleteSession(path string) error {\n\tdir := a.activeSessionDir()\n\tsessionPath, key, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {\n\t\treturn err\n\t}\n\tremoved, fallback := a.removeSessionRuntimeBindings(dir, sessionPath)\n\tif err := prepareRemovedSessionRuntimes(removed); err != nil {\n\t\ta.closeRemovedSessionRuntimes(removed)\n\t\treturn err\n\t}\n\tdestroys := a.destroyHandlesForSession(dir, sessionPath, removed)\n\tif waitDestroyHandles(destroys) {\n\t\tif err := agent.MarkCleanupPending(sessionPath, \"delete\"); err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t\tgo delayedDesktopSessionTrash(dir, sessionPath, key, destroys)\n\t} else {\n\t\terr = trashSessionArtifacts(dir, sessionPath, key)\n\t\tfinishDestroyHandles(destroys)\n\t\tif err != nil {\n\t\t\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\t\t\treturn err\n\t\t}\n\t}\n\ta.closeRemovedSessionRuntimesAfterDestroy(removed)\n\tif fallback.needs {\n\t\tif err := a.openFallbackRuntime(fallback); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\ntype removedSessionRuntime struct {\n\ttab *WorkspaceTab\n\tctrl *control.Controller\n\tsink *tabEventSink\n\tsessionDir string\n\tsessionPath string\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\ntype fallbackRuntimeTarget struct {\n\tneeds bool\n\tscope string\n\tworkspaceRoot string\n\ttopicID string\n}\n\nfunc (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif !tabMatchesSession(tab, dir, sessionPath) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {\n\tvar removed []removedSessionRuntime\n\tvar fallback fallbackRuntimeTarget\n\n\ta.mu.Lock()\n\tfor id, tab := range a.tabs {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.tabs, id)\n\t\ta.removeTabOrderLocked(id)\n\t\tif a.activeTabID == id {\n\t\t\ta.activeTabID = \"\"\n\t\t}\n\t}\n\tfor key, tab := range a.detachedSessions {\n\t\tif tab == nil || tab.TopicID != topicID {\n\t\t\tcontinue\n\t\t}\n\t\tsessionDir := tabSessionDir(tab)\n\t\tsessionPath := canonicalTabSessionPath(tab.currentSessionPath())\n\t\tif len(removed) == 0 {\n\t\t\tfallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}\n\t\t}\n\t\tremoved = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))\n\t\tdelete(a.detachedSessions, key)\n\t}\n\tif a.activeTabID == \"\" && len(a.tabOrder) > 0 {\n\t\ta.activeTabID = a.tabOrder[0]\n\t}\n\tfallback.needs = len(removed) > 0 && len(a.tabs) == 0\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\treturn removed, fallback\n}\n\nfunc removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {\n\treturn removedSessionRuntime{\n\t\ttab: tab,\n\t\tctrl: tab.Ctrl,\n\t\tsink: tab.sink,\n\t\tsessionDir: dir,\n\t\tsessionPath: sessionPath,\n\t\tscope: tab.Scope,\n\t\tworkspaceRoot: tab.WorkspaceRoot,\n\t\ttopicID: tab.TopicID,\n\t}\n}\n\nfunc tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {\n\tif tab == nil || tabSessionDir(tab) != dir {\n\t\treturn false\n\t}\n\tcurrentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())\n\treturn err == nil && currentPath == sessionPath\n}\n\nfunc prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {\n\tfor _, item := range removed {\n\t\tif item.sink != nil {\n\t\t\titem.sink.ctx = nil\n\t\t}\n\t\tif item.ctrl == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif item.ctrl.Running() {\n\t\t\titem.ctrl.Cancel()\n\t\t\tif err := waitControllerStopped(item.ctrl); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif err := item.ctrl.Snapshot(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc waitControllerStopped(ctrl *control.Controller) error {\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor ctrl.Running() {\n\t\tif time.Now().After(deadline) {\n\t\t\treturn fmt.Errorf(\"timed out waiting for cancelled session work to stop\")\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\treturn nil\n}\n\nfunc (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {\n\tdestroys := a.beginDestroySessionJobs(dir, sessionPath)\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {\n\ttimedOut := false\n\tfor _, destroy := range destroys {\n\t\tif destroy.Wait != nil {\n\t\t\tif destroy.Wait().HasTimedOut() {\n\t\t\t\ttimedOut = true\n\t\t\t}\n\t\t}\n\t}\n\treturn timedOut\n}\n\nfunc waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.WaitAll != nil {\n\t\t\tdestroy.WaitAll()\n\t\t}\n\t}\n}\n\nfunc finishDestroyHandles(destroys []control.SessionDestroyHandle) {\n\tfor _, destroy := range destroys {\n\t\tif destroy.Finish != nil {\n\t\t\tdestroy.Finish()\n\t\t}\n\t}\n}\n\nfunc delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := removeDesktopSessionArtifacts(path); err != nil {\n\t\tslog.Warn(\"desktop: delayed session cleanup failed\", \"path\", path, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {\n\twaitAllDestroyHandles(destroys)\n\tif err := trashSessionArtifacts(dir, sessionPath, key); err != nil {\n\t\tslog.Warn(\"desktop: delayed session trash failed\", \"path\", sessionPath, \"err\", err)\n\t}\n\tfinishDestroyHandles(destroys)\n}\n\nfunc (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {\n\tseen := map[*control.Controller]bool{}\n\treleasedTabs := map[*WorkspaceTab]bool{}\n\tfor _, item := range removed {\n\t\tif item.tab != nil && !releasedTabs[item.tab] {\n\t\t\treleasedTabs[item.tab] = true\n\t\t\ta.releaseTabSharedHost(item.tab)\n\t\t}\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.Close()\n\t}\n}\n\nfunc (a *App) closeRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime) {\n\tseen := map[*control.Controller]bool{}\n\tfor _, item := range removed {\n\t\tif item.ctrl == nil || seen[item.ctrl] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[item.ctrl] = true\n\t\titem.ctrl.CloseAfterDestroy()\n\t}\n}\n\nfunc (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {\n\tscope := target.scope\n\troot := target.workspaceRoot\n\ttopicID := strings.TrimSpace(target.topicID)\n\tif scope == \"global\" {\n\t\troot = \"\"\n\t}\n\tif topicID == \"\" {\n\t\ttopic, err := a.CreateTopic(scope, root, \"\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicID = topic.ID\n\t}\n\tvar err error\n\tif scope == \"global\" {\n\t\t_, err = a.OpenGlobalTab(topicID)\n\t} else {\n\t\t_, err = a.OpenProjectTab(root, topicID)\n\t}\n\treturn err\n}\n\nfunc (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tvar destroys []control.SessionDestroyHandle\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tdestroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))\n\t}\n\treturn destroys\n}\n\nfunc (a *App) openSessionPaths(dir string) map[string]struct{} {\n\ta.mu.RLock()\n\tpaths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab != nil {\n\t\t\tpaths = append(paths, tab.currentSessionPath())\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\n\tout := make(map[string]struct{}, len(paths))\n\tfor _, path := range paths {\n\t\tcurrentPath, _, err := validateSessionPath(dir, path)\n\t\tif err == nil {\n\t\t\tout[currentPath] = struct{}{}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (a *App) activeSessionPath(dir string) string {\n\ta.mu.RLock()\n\tvar path string\n\tif tab := a.tabs[a.activeTabID]; tab != nil {\n\t\tpath = tab.currentSessionPath()\n\t}\n\ta.mu.RUnlock()\n\tcurrentPath, _, err := validateSessionPath(dir, path)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// RestoreSession moves a trashed session back into the saved-session list.\nfunc (a *App) RestoreSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, key, _, err := validateTrashedSessionPath(dir, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttarget := filepath.Join(dir, key)\n\tif a.sessionDestroying(dir, target) {\n\t\treturn fmt.Errorf(\"session cleanup is still in progress: %s\", key)\n\t}\n\tif err := restoreTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\tif err := restoreSessionTopicIndex(dir, target); err != nil {\n\t\treturn err\n\t}\n\ta.emitProjectTreeChanged()\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\nfunc (a *App) sessionDestroying(dir, sessionPath string) bool {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tfor _, tab := range a.runtimeTabsLocked() {\n\t\tif tab == nil || tab.Ctrl == nil || tabSessionDir(tab) != dir {\n\t\t\tcontinue\n\t\t}\n\t\tif tab.Ctrl.IsDestroyingSession(sessionPath) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PurgeTrashedSession permanently removes a trashed session and its title/display\n// sidecars.\nfunc (a *App) PurgeTrashedSession(path string) error {\n\tdir, err := a.trashedSessionDir(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := purgeTrashedSessionFile(dir, path); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// RenameSession sets a custom display name for a session (empty clears it back to\n// the preview). It only affects the history panel; the file on disk is unchanged.\nfunc (a *App) RenameSession(path, title string) error {\n\tif err := setSessionTitle(a.activeSessionDir(), path, title); err != nil {\n\t\treturn err\n\t}\n\ta.invalidatePromptHistoryCache()\n\treturn nil\n}\n\n// ResumeSession snapshots the current conversation, then loads the session at\n// path and continues it on the active tab. The model and working folder are\n// unchanged; only the transcript is swapped. Returns the resumed messages for\n// the frontend to render.\nfunc (a *App) ResumeSession(path string) ([]HistoryMessage, error) {\n\treturn a.ResumeSessionForTab(\"\", path)\n}\n\n// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session\n// path is a runtime identity, so changing to a different path must replace the\n// tab's controller binding rather than mutating the current controller in place.\nfunc (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn a.HistoryForTab(tabID), nil\n\t}\n\n\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\treturn nil, err\n\t}\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {\n\ttab := a.tabByID(tabID)\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn []HistoryMessage{}, fmt.Errorf(\"tab is not ready\")\n\t}\n\tctrl := tab.Ctrl\n\tsessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {\n\t\tif err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\ta.setTabReadOnly(tab.ID, true)\n\treturn a.HistoryForTab(tab.ID), nil\n}\n\nfunc (a *App) setTabReadOnly(tabID string, readOnly bool) {\n\ta.mu.Lock()\n\tif tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {\n\t\ttab.ReadOnly = readOnly\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\nfunc (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tloaded, err := loadResumableSession(sessionPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)\n}\n\nfunc (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"tab is not ready\")\n\t}\n\tsessionPath = canonicalTabSessionPath(sessionPath)\n\tif sessionPath == \"\" {\n\t\treturn fmt.Errorf(\"session path is required\")\n\t}\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn fmt.Errorf(\"session is pending cleanup\")\n\t}\n\tif loaded == nil {\n\t\tvar err error\n\t\tloaded, err = loadResumableSession(sessionPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {\n\t\treturn nil\n\t}\n\n\tctrl := tab.Ctrl\n\tif ctrl == nil {\n\t\ta.mu.Lock()\n\t\ttab.SessionPath = sessionPath\n\t\ttab.Ready = false\n\t\ttab.StartupErr = \"\"\n\t\ttab.ActivityStatus = \"\"\n\t\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\t\ta.saveTabsLocked()\n\t\ta.mu.Unlock()\n\t\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\t\tif tab.Ctrl == nil {\n\t\t\tif tab.StartupErr != \"\" {\n\t\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t\t}\n\t\treturn nil\n\t}\n\n\t_ = ctrl.Snapshot() // persist the current session before switching the view.\n\tif tab.hasActiveRuntimeWork() {\n\t\tif !a.detachRuntimeForReplacement(tab) {\n\t\t\treturn fmt.Errorf(\"current session runtime cannot be detached\")\n\t\t}\n\t} else {\n\t\tctrl.Close()\n\t}\n\n\ta.mu.Lock()\n\ttab.Ctrl = nil\n\ttab.SessionPath = sessionPath\n\ttab.Ready = false\n\ttab.StartupErr = \"\"\n\ttab.ActivityStatus = \"\"\n\ttab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\ta.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})\n\tif tab.Ctrl == nil {\n\t\tif tab.StartupErr != \"\" {\n\t\t\treturn fmt.Errorf(\"resume session: %s\", tab.StartupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"resume session: controller was not built\")\n\t}\n\treturn nil\n}\n\nfunc loadResumableSession(sessionPath string) (*agent.Session, error) {\n\tif agent.IsCleanupPending(sessionPath) {\n\t\treturn nil, fmt.Errorf(\"session is pending cleanup\")\n\t}\n\treturn agent.LoadSession(sessionPath)\n}\n\n// PreviewSession reads a saved session for display only. It does not snapshot or\n// swap the active controller, so the history drawer can call it while a turn runs.\nfunc (a *App) PreviewSession(path string) ([]HistoryMessage, error) {\n\tsessionDir, sessionPath, err := a.sessionDirForPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn previewSessionMessages(sessionDir, sessionPath)\n}\n\n// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next\n// ScanPromptHistory call rebuilds session order and reloads sessions on demand.\n// Called from every session-mutating path: NewSession, ClearSession,\n// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.\nfunc (a *App) invalidatePromptHistoryCache() {\n\ta.promptHistoryMu.Lock()\n\ta.promptHistoryTape = nil\n\ta.promptHistoryMu.Unlock()\n}\n\nconst (\n\tpromptHistoryPageLimit = 50\n\tpromptHistoryMaxPageLimit = 200\n)\n\ntype promptHistoryRequest struct {\n\tNonce string `json:\"nonce,omitempty\"`\n\tCursor string `json:\"cursor,omitempty\"`\n\tLimit int `json:\"limit,omitempty\"`\n\tlegacy bool\n}\n\ntype promptHistoryCursor struct {\n\tNonce string `json:\"n\"`\n\tSession int `json:\"s\"`\n\tOffset int `json:\"o\"`\n}\n\ntype promptHistoryTape struct {\n\tnonce string\n\tdir string\n\tcurrentPath string\n\tdisplays sessionDisplayMap\n\tsessions []promptHistorySessionFile\n\tloaded map[string][]PromptHistoryEntry\n}\n\n// ScanPromptHistory returns the next prompt-history tape segment. The request is\n// a JSON string so the Wails binding stays one-argument while the protocol can\n// carry a cursor and page limit. Older clients may still pass a bare nonce; that\n// path keeps the old cache-hit behavior.\nfunc (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {\n\treq := parsePromptHistoryRequest(rawRequest)\n\tdir := a.activeSessionDir()\n\tsessionPath := a.activeSessionPath(dir)\n\n\ta.promptHistoryMu.Lock()\n\ttape, err := a.promptHistoryTapeForLocked(dir, sessionPath)\n\tif err != nil {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{}, err\n\t}\n\tif req.legacy && req.Nonce != \"\" && req.Nonce == tape.nonce {\n\t\ta.promptHistoryMu.Unlock()\n\t\treturn PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil\n\t}\n\tresult := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))\n\ta.promptHistoryMu.Unlock()\n\treturn result, nil\n}\n\nfunc parsePromptHistoryRequest(raw string) promptHistoryRequest {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn promptHistoryRequest{}\n\t}\n\tif strings.HasPrefix(raw, \"{\") {\n\t\tvar req promptHistoryRequest\n\t\tif err := json.Unmarshal([]byte(raw), &req); err == nil {\n\t\t\treturn req\n\t\t}\n\t}\n\treturn promptHistoryRequest{Nonce: raw, legacy: true}\n}\n\nfunc promptHistoryLimit(limit int) int {\n\tif limit <= 0 {\n\t\treturn promptHistoryPageLimit\n\t}\n\tif limit > promptHistoryMaxPageLimit {\n\t\treturn promptHistoryMaxPageLimit\n\t}\n\treturn limit\n}\n\nfunc (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {\n\tcurrentPath := \"\"\n\tif path, _, err := validateSessionPath(dir, sessionPath); err == nil {\n\t\tcurrentPath = path\n\t}\n\tif a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {\n\t\treturn a.promptHistoryTape, nil\n\t}\n\ttape, err := newPromptHistoryTape(dir, currentPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.promptHistoryTape = tape\n\treturn tape, nil\n}\n\nfunc (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {\n\ttape, err := newPromptHistoryTape(dir, \"\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tape.readAll(), nil\n}\n\nfunc newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {\n\ttape := &promptHistoryTape{\n\t\tnonce: fmt.Sprintf(\"%d\", time.Now().UnixNano()),\n\t\tdir: dir,\n\t\tcurrentPath: currentPath,\n\t\tdisplays: loadSessionDisplays(dir),\n\t\tloaded: map[string][]PromptHistoryEntry{},\n\t}\n\tsessions, err := promptHistorySessionFiles(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif currentPath != \"\" {\n\t\tcurrentPath = filepath.Clean(currentPath)\n\t\tcurrentSession := promptHistorySessionFile{}\n\t\tcurrentIndex := -1\n\t\tfor i, session := range sessions {\n\t\t\tif filepath.Clean(session.path) == currentPath {\n\t\t\t\tcurrentSession = session\n\t\t\t\tcurrentIndex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif currentIndex >= 0 {\n\t\t\tsessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)\n\t\t} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {\n\t\t\tsessions = append([]promptHistorySessionFile{{\n\t\t\t\tpath: currentPath,\n\t\t\t}}, sessions...)\n\t\t}\n\t}\n\ttape.sessions = sessions\n\treturn tape, nil\n}\n\nfunc (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {\n\tc := promptHistoryCursor{Nonce: t.nonce}\n\tif decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {\n\t\tc = decoded\n\t}\n\tif c.Session < 0 {\n\t\tc.Session = 0\n\t}\n\tif c.Offset < 0 {\n\t\tc.Offset = 0\n\t}\n\n\tout := make([]PromptHistoryEntry, 0, limit)\n\tsessionIndex := c.Session\n\toffset := c.Offset\n\tfor sessionIndex < len(t.sessions) && len(out) < limit {\n\t\tentries, err := t.entriesForSession(sessionIndex)\n\t\tif err != nil || offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tend := min(len(entries), offset+limit-len(out))\n\t\tout = append(out, entries[offset:end]...)\n\t\toffset = end\n\t\tif offset >= len(entries) && len(out) < limit {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\n\tif sessionIndex < len(t.sessions) {\n\t\tif entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {\n\t\t\tsessionIndex++\n\t\t\toffset = 0\n\t\t}\n\t}\n\thasOlder := sessionIndex < len(t.sessions)\n\tolderCursor := \"\"\n\tif hasOlder {\n\t\tolderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})\n\t}\n\treturn PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}\n}\n\nfunc (t *promptHistoryTape) readAll() []PromptHistoryEntry {\n\tout := []PromptHistoryEntry{}\n\tcursor := \"\"\n\tfor {\n\t\tpage := t.readOlder(cursor, promptHistoryMaxPageLimit)\n\t\tout = append(out, page.Entries...)\n\t\tif !page.HasOlder || page.OlderCursor == \"\" {\n\t\t\treturn out\n\t\t}\n\t\tcursor = page.OlderCursor\n\t}\n}\n\nfunc (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {\n\tif index < 0 || index >= len(t.sessions) {\n\t\treturn nil, nil\n\t}\n\tpath := t.sessions[index].path\n\tif entries, ok := t.loaded[path]; ok {\n\t\treturn entries, nil\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tentries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))\n\tif err != nil {\n\t\tt.loaded[path] = nil\n\t\treturn nil, err\n\t}\n\tt.loaded[path] = entries\n\treturn entries, nil\n}\n\nfunc encodePromptHistoryCursor(cursor promptHistoryCursor) string {\n\tb, err := json.Marshal(cursor)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn base64.RawURLEncoding.EncodeToString(b)\n}\n\nfunc decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tb, err := base64.RawURLEncoding.DecodeString(value)\n\tif err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\tvar cursor promptHistoryCursor\n\tif err := json.Unmarshal(b, &cursor); err != nil {\n\t\treturn promptHistoryCursor{}, false\n\t}\n\treturn cursor, true\n}\n\nfunc scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tentries, err := collectPromptHistoryEntries(path, info, resolveUserContent)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortPromptHistoryNewestFirst(entries)\n\treturn entries, nil\n}\n\ntype promptHistorySessionFile struct {\n\tpath string\n}\n\nfunc promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {\n\tinfos, err := agent.ListSessionOrder(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsessions := make([]promptHistorySessionFile, 0, len(infos))\n\tfor _, info := range infos {\n\t\tsessions = append(sessions, promptHistorySessionFile{path: info.Path})\n\t}\n\treturn sessions, nil\n}\n\nfunc promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {\n\tif a.At != b.At {\n\t\treturn a.At > b.At\n\t}\n\tif a.SessionPath != b.SessionPath {\n\t\treturn a.SessionPath > b.SessionPath\n\t}\n\treturn a.Turn > b.Turn\n}\n\nfunc sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {\n\tsort.Slice(entries, func(i, j int) bool {\n\t\treturn promptHistoryEntryNewer(entries[i], entries[j])\n\t})\n}\n\nfunc collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {\n\tvar out []PromptHistoryEntry\n\terr := collectJSONLUserPrompts(path, info, resolveUserContent, func(entry PromptHistoryEntry) {\n\t\tout = append(out, entry)\n\t})\n\treturn out, err\n}\n\nfunc collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfallbackAt := promptHistoryFallbackMillis(path, info)\n\n\tdec := json.NewDecoder(f)\n\tturn := 0\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\treturn nil // partial results are better than none\n\t\t}\n\t\t// Format compatibility:\n\t\t// 1) Legacy event format: {\"kind\":\"user.message\",\"text\":\"...\"}\n\t\t// 2) Early event format: {\"type\":\"user.message\",\"text\":\"...\"}\n\t\t// 3) Current provider.Message format: {\"role\":\"user\",\"content\":\"...\"}\n\t\ttext := \"\"\n\t\tkindOrType := strings.TrimSpace(rec.Kind)\n\t\tif kindOrType == \"\" {\n\t\t\tkindOrType = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif kindOrType == \"user.message\" {\n\t\t\ttext = strings.TrimSpace(rec.Text)\n\t\t} else if strings.TrimSpace(rec.Role) == \"user\" {\n\t\t\ttext = strings.TrimSpace(rec.Content)\n\t\t}\n\t\tif text != \"\" {\n\t\t\ttext = resolveUserContent(text)\n\t\t\ttext = strings.TrimSpace(text)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif control.IsSyntheticUserMessage(text) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tat := fallbackAt\n\t\t\tif eventAt, ok := promptHistoryEventMillis(rec); ok {\n\t\t\t\tat = eventAt\n\t\t\t}\n\t\t\tentry := PromptHistoryEntry{\n\t\t\t\tText: text,\n\t\t\t\tAt: at,\n\t\t\t\tSessionPath: path,\n\t\t\t\tTurn: turn,\n\t\t\t}\n\t\t\temit(entry)\n\t\t\tturn++\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {\n\tif meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {\n\t\treturn meta.UpdatedAt.UnixMilli()\n\t}\n\tif info != nil {\n\t\treturn info.ModTime().UnixMilli()\n\t}\n\treturn 0\n}\n\nfunc promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {\n\tfor _, raw := range []json.RawMessage{\n\t\trec.Time,\n\t\trec.Timestamp,\n\t\trec.CreatedAt,\n\t\trec.CreatedAtSnake,\n\t\trec.UpdatedAt,\n\t\trec.UpdatedAtSnake,\n\t} {\n\t\tif at, ok := parseJSONTimestampMillis(raw); ok {\n\t\t\treturn at, true\n\t\t}\n\t}\n\treturn 0, false\n}\n\nfunc parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {\n\tif len(raw) == 0 || bytes.Equal(raw, []byte(\"null\")) {\n\t\treturn 0, false\n\t}\n\n\tvar s string\n\tif err := json.Unmarshal(raw, &s); err == nil {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\treturn 0, false\n\t\t}\n\t\tif n, err := strconv.ParseInt(s, 10, 64); err == nil {\n\t\t\treturn normalizeTimestampMillis(n)\n\t\t}\n\t\tif f, err := strconv.ParseFloat(s, 64); err == nil {\n\t\t\treturn normalizeTimestampMillisFloat(f)\n\t\t}\n\t\tif t, err := time.Parse(time.RFC3339Nano, s); err == nil {\n\t\t\treturn t.UnixMilli(), true\n\t\t}\n\t\treturn 0, false\n\t}\n\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.UseNumber()\n\tvar n json.Number\n\tif err := dec.Decode(&n); err != nil {\n\t\treturn 0, false\n\t}\n\tif i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {\n\t\treturn normalizeTimestampMillis(i)\n\t}\n\tif f, err := strconv.ParseFloat(n.String(), 64); err == nil {\n\t\treturn normalizeTimestampMillisFloat(f)\n\t}\n\treturn 0, false\n}\n\nfunc normalizeTimestampMillis(v int64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn v / 1_000_000, true // nanoseconds\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn v / 1_000, true // microseconds\n\tcase v >= 100_000_000_000:\n\t\treturn v, true // milliseconds\n\tcase v >= 1_000_000_000:\n\t\treturn v * 1_000, true // seconds\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\nfunc normalizeTimestampMillisFloat(v float64) (int64, bool) {\n\tif v <= 0 {\n\t\treturn 0, false\n\t}\n\tswitch {\n\tcase v >= 1_000_000_000_000_000_000:\n\t\treturn int64(v / 1_000_000), true\n\tcase v >= 1_000_000_000_000_000:\n\t\treturn int64(v / 1_000), true\n\tcase v >= 100_000_000_000:\n\t\treturn int64(v), true\n\tcase v >= 1_000_000_000:\n\t\treturn int64(v * 1_000), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab\n// scoped to that folder. Returns the chosen path (\"\" if cancelled).\nfunc (a *App) PickWorkspace() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tcur = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose working folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn a.SwitchWorkspace(dir)\n}\n\nfunc dialogDefaultDirectory(preferred string) string {\n\tif dir := nearestExistingDirectory(preferred); dir != \"\" {\n\t\treturn dir\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tif dir := nearestExistingDirectory(cwd); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif dir := nearestExistingDirectory(home); dir != \"\" {\n\t\t\treturn dir\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc nearestExistingDirectory(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tfor {\n\t\tinfo, err := os.Stat(path)\n\t\tif err == nil {\n\t\t\tif info.IsDir() {\n\t\t\t\treturn path\n\t\t\t}\n\t\t\tpath = filepath.Dir(path)\n\t\t\tcontinue\n\t\t}\n\t\tparent := filepath.Dir(path)\n\t\tif parent == path {\n\t\t\treturn \"\"\n\t\t}\n\t\tpath = parent\n\t}\n}\n\nfunc (a *App) ListWorkspaces() []WorkspaceMeta {\n\tmigrateLegacyWorkspacesIntoProjects()\n\tactiveRoot := \"\"\n\tcur, _ := os.Getwd()\n\ta.mu.RLock()\n\tif tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != \"\" {\n\t\tactiveRoot = normalizeProjectRoot(tab.WorkspaceRoot)\n\t}\n\ta.mu.RUnlock()\n\tif activeRoot == \"\" {\n\t\tactiveRoot = normalizeProjectRoot(cur)\n\t}\n\tprojects := loadProjectsFile().Projects\n\tout := make([]WorkspaceMeta, 0, len(projects))\n\tfor _, project := range projects {\n\t\tout = append(out, WorkspaceMeta{\n\t\t\tPath: project.Root,\n\t\t\tName: projectDisplayName(project),\n\t\t\tCurrent: activeRoot != \"\" && project.Root == activeRoot,\n\t\t})\n\t}\n\treturn out\n}\n\nfunc (a *App) RemoveWorkspace(dir string) error {\n\tif dir == \"\" {\n\t\treturn fmt.Errorf(\"workspace path is required\")\n\t}\n\tdir = normalizeProjectRoot(dir)\n\tforgetWorkspace(dir)\n\tif err := removeProject(dir); err != nil {\n\t\treturn err\n\t}\n\t// If the removed workspace was the active one, clear the pointer\n\t// so we don't leave a stale reference to a deleted project.\n\tif loadWorkspace() == dir {\n\t\tif remaining := loadProjectsFile(); len(remaining.Projects) > 0 {\n\t\t\t// Fall back to the first remaining project\n\t\t\tsaveWorkspace(remaining.Projects[0].Root)\n\t\t} else {\n\t\t\t// No projects left; clear the active pointer entirely\n\t\t\tclearWorkspace()\n\t\t}\n\t}\n\ta.emitProjectTreeChanged()\n\treturn nil\n}\n\nfunc migrateLegacyWorkspacesIntoProjects() {\n\tlegacy := loadWorkspaces()\n\tif len(legacy) == 0 {\n\t\treturn\n\t}\n\tf := loadProjectsFile()\n\tseen := make(map[string]bool, len(f.Projects)+len(legacy))\n\tfor _, p := range f.Projects {\n\t\tseen[p.Root] = true\n\t}\n\tchanged := false\n\tfor _, path := range legacy {\n\t\troot := normalizeProjectRoot(path)\n\t\tif root == \"\" || seen[root] {\n\t\t\tcontinue\n\t\t}\n\t\tf.Projects = append(f.Projects, desktopProject{Root: root})\n\t\tseen[root] = true\n\t\tchanged = true\n\t}\n\tif changed {\n\t\t_ = saveProjectsFile(f)\n\t}\n}\n\nfunc workspaceName(path string) string {\n\tname := filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) || name == \"\" {\n\t\treturn path\n\t}\n\treturn name\n}\n\nfunc tabWorkspaceName(tab *WorkspaceTab, cwd string) string {\n\tif tab.Scope == \"global\" {\n\t\treturn globalProjectTitle()\n\t}\n\treturn workspaceName(cwd)\n}\n\nfunc (a *App) SwitchWorkspace(dir string) (string, error) {\n\tif dir == \"\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdir = home\n\t}\n\tif abs, err := filepath.Abs(dir); err == nil {\n\t\tdir = abs\n\t}\n\tinfo, err := os.Stat(dir)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"%s is not a directory\", dir)\n\t}\n\tsaveWorkspace(dir)\n\n\t// Open a registered topic so the new workspace appears in the project tree\n\t// immediately instead of only existing as an in-memory tab.\n\ttopic, err := a.CreateTopic(\"project\", dir, \"\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmeta, err := a.OpenProjectTab(dir, topic.ID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn meta.WorkspaceRoot, nil\n}\n\n// HistoryMessage is one prior turn, for the frontend to repopulate its transcript\n// after a reload.\ntype HistoryMessage struct {\n\tRole string `json:\"role\"`\n\tContent string `json:\"content\"`\n\tSubmitText string `json:\"submitText,omitempty\"`\n\tReasoning string `json:\"reasoning,omitempty\"`\n\tLevel string `json:\"level,omitempty\"`\n\tToolCalls []HistoryToolCall `json:\"toolCalls,omitempty\"`\n\tToolCallID string `json:\"toolCallId,omitempty\"`\n\tToolName string `json:\"toolName,omitempty\"`\n\tToolResultArchived bool `json:\"toolResultArchived,omitempty\"`\n\tToolResultError string `json:\"toolResultError,omitempty\"`\n\tPending bool `json:\"pending,omitempty\"`\n\tTrigger string `json:\"trigger,omitempty\"`\n\tMessages int `json:\"messages,omitempty\"`\n\tSummary string `json:\"summary,omitempty\"`\n\tArchive string `json:\"archive,omitempty\"`\n}\n\ntype HistoryToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n}\n\n// History returns the session's message log.\nfunc (a *App) History() []HistoryMessage {\n\treturn a.HistoryForTab(\"\")\n}\n\nfunc (a *App) HistoryForTab(tabID string) []HistoryMessage {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn []HistoryMessage{}\n\t}\n\tmsgs := ctrl.History()\n\treturn historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()))\n}\n\nfunc historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {\n\tout := make([]HistoryMessage, 0, len(msgs))\n\treplayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)\n\tfor _, m := range msgs {\n\t\tcontent := m.Content\n\t\tif m.Role == provider.RoleUser {\n\t\t\t// Mid-turn steer messages are persisted in the session so they\n\t\t\t// survive tab switches. They are surfaced as a notice (↪ text)\n\t\t\t// — matching the live Steer event look — rather than as a\n\t\t\t// regular user bubble or being filtered as synthetic (#4044).\n\t\t\t// Check against the raw m.Content: resolveUserContent applies\n\t\t\t// StripComposePrefixes which trims trailing whitespace.\n\t\t\tif steerText, isSteer := agent.SteerText(m.Content); isSteer {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Content: \"↪ \" + steerText})\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent = resolveUserContent(m.Content)\n\t\t\tif control.IsSyntheticUserMessage(content) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treasoning := \"\"\n\t\tif m.Role == provider.RoleAssistant {\n\t\t\treasoning = m.ReasoningContent\n\t\t}\n\t\thm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning}\n\t\tif m.Role == provider.RoleUser && content != m.Content {\n\t\t\thm.SubmitText = m.Content\n\t\t}\n\t\tif m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\thm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))\n\t\t\tfor i, tc := range m.ToolCalls {\n\t\t\t\targs := tc.Arguments\n\t\t\t\tif tc.Name == \"todo_write\" {\n\t\t\t\t\tif replayed, ok := replayedTodoArgs[tc.ID]; ok {\n\t\t\t\t\t\targs = replayed\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thm.ToolCalls[i] = HistoryToolCall{ID: tc.ID, Name: tc.Name, Arguments: args}\n\t\t\t}\n\t\t}\n\t\tif m.Role == provider.RoleTool {\n\t\t\thm.ToolCallID = m.ToolCallID\n\t\t\thm.ToolName = m.Name\n\t\t}\n\t\tout = append(out, hm)\n\t}\n\treturn out\n}\n\nfunc historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {\n\tsuccessful := successfulHistoryToolCallIDs(msgs)\n\tout := map[string]string{}\n\tvar todos []evidence.TodoItem\n\tlatestTodoID := \"\"\n\tfor _, m := range msgs {\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\tif tc.ID == \"\" || !successful[tc.ID] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch tc.Name {\n\t\t\tcase \"todo_write\":\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tif len(rec.Todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos = append([]evidence.TodoItem(nil), rec.Todos...)\n\t\t\t\tlatestTodoID = tc.ID\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\tcase \"complete_step\":\n\t\t\t\tif latestTodoID == \"\" || len(todos) == 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)\n\t\t\t\tmatch, ok := evidence.MatchStep(rec.Step, todos)\n\t\t\t\tif !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == \"completed\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttodos[match.Index-1].Status = \"completed\"\n\t\t\t\tpromoteNextHistoryTodo(todos)\n\t\t\t\tif args, ok := todoArgsJSON(todos); ok {\n\t\t\t\t\tout[latestTodoID] = args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn out\n}\n\nfunc successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {\n\tsuccessful := map[string]bool{}\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool || msg.ToolCallID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif !historyToolResultFailed(msg.Content) {\n\t\t\tsuccessful[msg.ToolCallID] = true\n\t\t}\n\t}\n\treturn successful\n}\n\nfunc historyToolResultFailed(content string) bool {\n\tcontent = strings.TrimSpace(content)\n\treturn strings.HasPrefix(content, \"error:\") ||\n\t\tstrings.HasPrefix(content, \"blocked:\") ||\n\t\tstrings.HasPrefix(content, \"Error:\") ||\n\t\tstrings.HasPrefix(content, \"[error\")\n}\n\nfunc todoArgsJSON(todos []evidence.TodoItem) (string, bool) {\n\tb, err := json.Marshal(map[string]any{\"todos\": todos})\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\treturn string(b), true\n}\n\nfunc promoteNextHistoryTodo(todos []evidence.TodoItem) {\n\tfor _, todo := range todos {\n\t\tif todoStatusForHistory(todo.Status) == \"in_progress\" {\n\t\t\treturn\n\t\t}\n\t}\n\tfor i := range todos {\n\t\tif todoStatusForHistory(todos[i].Status) == \"pending\" {\n\t\t\ttodos[i].Status = \"in_progress\"\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc todoStatusForHistory(status string) string {\n\tstatus = strings.TrimSpace(status)\n\tif status == \"\" {\n\t\treturn \"pending\"\n\t}\n\treturn status\n}\n\nfunc previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {\n\tsessionPath, _, err := validateSessionPath(sessionDir, path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {\n\t\treturn out, err\n\t}\n\tloaded, err := agent.LoadSession(sessionPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil\n}\n\ntype previewEventRecord struct {\n\tKind string `json:\"kind\"`\n\tType string `json:\"type\"`\n\tRole string `json:\"role\"`\n\tTime json.RawMessage `json:\"time\"`\n\tTimestamp json.RawMessage `json:\"timestamp\"`\n\tCreatedAt json.RawMessage `json:\"createdAt\"`\n\tCreatedAtSnake json.RawMessage `json:\"created_at\"`\n\tUpdatedAt json.RawMessage `json:\"updatedAt\"`\n\tUpdatedAtSnake json.RawMessage `json:\"updated_at\"`\n\tText string `json:\"text\"`\n\tContent string `json:\"content\"`\n\tReasoning string `json:\"reasoning\"`\n\tReasoningContent string `json:\"reasoningContent\"`\n\tLevel string `json:\"level\"`\n\tToolCalls []previewToolCall `json:\"toolCalls\"`\n\tCallID string `json:\"callId\"`\n\tToolCallID string `json:\"toolCallId\"`\n\tToolName string `json:\"toolName\"`\n\tName string `json:\"name\"`\n\tOutput string `json:\"output\"`\n\tCompaction *previewCompaction `json:\"compaction\"`\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\ntype previewToolCall struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n\tFunction struct {\n\t\tName string `json:\"name\"`\n\t\tArguments string `json:\"arguments\"`\n\t} `json:\"function\"`\n}\n\ntype previewCompaction struct {\n\tTrigger string `json:\"trigger\"`\n\tMessages int `json:\"messages\"`\n\tSummary string `json:\"summary\"`\n\tArchive string `json:\"archive\"`\n}\n\nfunc previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tdefer f.Close()\n\n\tdec := json.NewDecoder(f)\n\tout := []HistoryMessage{}\n\ttoolName := map[string]string{}\n\tsawEvent := false\n\tfor {\n\t\tvar rec previewEventRecord\n\t\tif err := dec.Decode(&rec); err != nil {\n\t\t\tif errors.Is(err, io.EOF) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sawEvent {\n\t\t\t\treturn out, true, nil\n\t\t\t}\n\t\t\treturn nil, false, nil\n\t\t}\n\t\teventName := strings.TrimSpace(rec.Kind)\n\t\tif eventName == \"\" {\n\t\t\teventName = strings.TrimSpace(rec.Type)\n\t\t}\n\t\tif eventName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tsawEvent = true\n\t\tswitch eventName {\n\t\tcase \"user.message\":\n\t\t\tif rec.Text != \"\" {\n\t\t\t\tout = append(out, HistoryMessage{Role: \"user\", Content: rec.Text})\n\t\t\t}\n\t\tcase \"model.final\":\n\t\t\thm := HistoryMessage{Role: \"assistant\", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}\n\t\t\tfor _, tc := range rec.ToolCalls {\n\t\t\t\tid := tc.ID\n\t\t\t\tname := firstNonEmpty(tc.Name, tc.Function.Name)\n\t\t\t\targs := firstNonEmpty(tc.Arguments, tc.Function.Arguments)\n\t\t\t\thm.ToolCalls = append(hm.ToolCalls, HistoryToolCall{ID: id, Name: name, Arguments: args})\n\t\t\t\tif id != \"\" {\n\t\t\t\t\ttoolName[id] = name\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, hm)\n\t\tcase \"tool.result\":\n\t\t\tcallID := firstNonEmpty(rec.CallID, rec.ToolCallID)\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\tToolCallID: callID,\n\t\t\t\tToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),\n\t\t\t\tContent: firstNonEmpty(rec.Output, rec.Content),\n\t\t\t})\n\t\tcase \"phase\":\n\t\t\tout = append(out, HistoryMessage{Role: \"phase\", Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"notice\":\n\t\t\tlevel := rec.Level\n\t\t\tif level != \"warn\" {\n\t\t\t\tlevel = \"info\"\n\t\t\t}\n\t\t\tout = append(out, HistoryMessage{Role: \"notice\", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})\n\t\tcase \"compaction_started\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{Role: \"compaction\", Pending: true, Trigger: c.Trigger})\n\t\tcase \"compaction_done\":\n\t\t\tc := rec.compactionPayload()\n\t\t\tout = append(out, HistoryMessage{\n\t\t\t\tRole: \"compaction\",\n\t\t\t\tTrigger: c.Trigger,\n\t\t\t\tMessages: c.Messages,\n\t\t\t\tSummary: c.Summary,\n\t\t\t\tArchive: c.Archive,\n\t\t\t})\n\t\t}\n\t}\n\treturn out, sawEvent, nil\n}\n\nfunc (r previewEventRecord) compactionPayload() previewCompaction {\n\tif r.Compaction != nil {\n\t\treturn *r.Compaction\n\t}\n\treturn previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif value != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used\n// and Window both zero means no context-window data yet.\ntype ContextInfo struct {\n\tUsed int `json:\"used\"`\n\tWindow int `json:\"window\"`\n\tSessionTokens int `json:\"sessionTokens\"`\n\tCompactRatio float64 `json:\"compactRatio,omitempty\"`\n}\n\n// ContextUsage returns the latest context-window gauge numbers.\nfunc (a *App) ContextUsage() ContextInfo {\n\treturn a.ContextUsageForTab(\"\")\n}\n\nfunc (a *App) ContextUsageForTab(tabID string) ContextInfo {\n\ta.mu.RLock()\n\ttab := a.tabByIDLocked(tabID)\n\tvar ctrl *control.Controller\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\n\tvar sessionTokens int\n\tif tab != nil {\n\t\tsessionTokens = tab.telemetrySnapshot().Usage.TotalTokens\n\t}\n\tif ctrl == nil {\n\t\treturn ContextInfo{SessionTokens: sessionTokens}\n\t}\n\tused, window := ctrl.ContextSnapshot()\n\treturn ContextInfo{Used: used, Window: window, SessionTokens: sessionTokens, CompactRatio: ctrl.CompactRatio()}\n}\n\n// BalanceInfo is the wallet-balance readout for the status bar. Available is true\n// only when a balance was fetched; Display is the formatted amount (e.g. \"¥110.00\")\n// and is \"\" when the active provider declares no balance_url — the frontend then\n// omits the readout. Err carries a fetch failure for an optional tooltip.\ntype BalanceInfo struct {\n\tAvailable bool `json:\"available\"`\n\tDisplay string `json:\"display\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\n// Balance queries the active provider's wallet balance (a network call). It\n// returns an empty (unavailable) readout when no provider balance_url is set, the\n// controller is down, or the fetch fails — so the status bar simply shows nothing\n// rather than an error.\nfunc (a *App) Balance() BalanceInfo {\n\treturn a.BalanceForTab(\"\")\n}\n\nfunc (a *App) BalanceForTab(tabID string) BalanceInfo {\n\tctrl := a.ctrlByTabID(tabID)\n\tif ctrl == nil {\n\t\treturn BalanceInfo{}\n\t}\n\tb, err := ctrl.Balance(a.ctx)\n\tif err != nil {\n\t\treturn BalanceInfo{Err: err.Error()}\n\t}\n\tif b == nil {\n\t\treturn BalanceInfo{} // provider declares no balance endpoint\n\t}\n\treturn BalanceInfo{Available: true, Display: b.Display()}\n}\n\n// JobView is one running background job (bash/task started with\n// run_in_background) for the status-bar indicator.\ntype JobView struct {\n\tID string `json:\"id\"`\n\tKind string `json:\"kind\"`\n\tLabel string `json:\"label\"`\n\tStatus string `json:\"status\"`\n\tStartedAt int64 `json:\"startedAt\"`\n}\n\n// Jobs returns the still-running background jobs for the status bar. It refreshes\n// on demand (mount, turn end, and on each notice the frontend receives).\nfunc (a *App) Jobs() []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(\"\")\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) JobsForTab(tabID string) []JobView {\n\tout := []JobView{}\n\tctrl := a.ctrlByTabID(tabID)\n\treturn a.jobsForCtrl(ctrl, out)\n}\n\nfunc (a *App) jobsForCtrl(ctrl *control.Controller, out []JobView) []JobView {\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tfor _, v := range ctrl.Jobs() {\n\t\tout = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})\n\t}\n\treturn out\n}\n\n// Meta describes the session for the frontend's header and status line.\ntype Meta struct {\n\tLabel string `json:\"label\"`\n\tReady bool `json:\"ready\"`\n\tStartupErr string `json:\"startupErr,omitempty\"`\n\tEventChannel string `json:\"eventChannel\"`\n\tCwd string `json:\"cwd\"`\n\tWorkspaceRoot string `json:\"workspaceRoot,omitempty\"`\n\tWorkspaceName string `json:\"workspaceName,omitempty\"`\n\tWorkspacePath string `json:\"workspacePath,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\tBypass bool `json:\"bypass\"` // legacy JSON key for YOLO/full-access tool auto-approval\n\tCollaborationMode string `json:\"collaborationMode\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tTokenMode string `json:\"tokenMode\"`\n\tGoal string `json:\"goal,omitempty\"`\n\tGoalStatus string `json:\"goalStatus,omitempty\"`\n}\n\n// Meta reports the model label, readiness, any startup error, the working\n// directory (for the status line), and the runtime event channel the frontend\n// subscribes to.\nfunc (a *App) Meta() Meta {\n\treturn a.MetaForTab(\"\")\n}\n\nfunc (a *App) MetaForTab(tabID string) Meta {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn Meta{EventChannel: eventChannel}\n\t}\n\tcwd := tab.WorkspaceRoot\n\tif cwd == \"\" {\n\t\tcwd, _ = os.Getwd()\n\t}\n\tautoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools()\n\tcollaborationMode := currentTabCollaborationMode(tab)\n\ttoolApprovalMode := currentTabToolApprovalMode(tab)\n\ttokenMode := currentTabTokenMode(tab)\n\tgoal := currentTabGoal(tab)\n\tgoalStatus := currentTabGoalStatus(tab)\n\treturn Meta{\n\t\tLabel: tab.Label,\n\t\tReady: tab.Ready,\n\t\tStartupErr: tab.StartupErr,\n\t\tEventChannel: eventChannel,\n\t\tCwd: cwd,\n\t\tWorkspaceRoot: cwd,\n\t\tWorkspaceName: tabWorkspaceName(tab, cwd),\n\t\tWorkspacePath: cwd,\n\t\tGitBranch: workspaceGitBranch(cwd),\n\t\tAutoApproveTools: autoApproveTools,\n\t\tBypass: autoApproveTools,\n\t\tCollaborationMode: collaborationMode,\n\t\tToolApprovalMode: toolApprovalMode,\n\t\tTokenMode: tokenMode,\n\t\tGoal: goal,\n\t\tGoalStatus: goalStatus,\n\t}\n}\n\nfunc (a *App) SetGoal(goal string) {\n\ta.SetGoalForTab(\"\", goal)\n}\n\nfunc (a *App) SetGoalForTab(tabID, goal string) {\n\tgoal = strings.TrimSpace(goal)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.goal = goal\n\tif goal != \"\" {\n\t\ttab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)\n\t}\n\tctrl := tab.Ctrl\n\tplan := tabModeHasPlan(tab.mode)\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tif ctrl != nil {\n\t\tctrl.SetPlanMode(plan)\n\t\tctrl.SetGoal(goal)\n\t}\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\nfunc (a *App) ClearGoal() {\n\ta.SetGoal(\"\")\n}\n\nfunc (a *App) ClearGoalForTab(tabID string) {\n\ta.SetGoalForTab(tabID, \"\")\n}\n\n// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:\n// approval-gated tool calls run without asking, while ask questions and plan\n// approvals still wait for the user. Runtime-only — not written to config.\nfunc (a *App) SetAutoApproveTools(on bool) {\n\tif on {\n\t\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalYolo)\n\t\treturn\n\t}\n\ta.SetToolApprovalModeForTab(\"\", control.ToolApprovalAsk)\n}\n\n// SetBypass is the legacy Wails binding for SetAutoApproveTools.\nfunc (a *App) SetBypass(on bool) {\n\ta.SetAutoApproveTools(on)\n}\n\nfunc (a *App) SetToolApprovalMode(mode string) {\n\ta.SetToolApprovalModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetToolApprovalModeForTab(tabID, mode string) {\n\tmode = normalizeToolApprovalMode(mode)\n\ta.mu.Lock()\n\ttab := a.tabByIDLocked(tabID)\n\tif tab == nil {\n\t\ta.mu.Unlock()\n\t\treturn\n\t}\n\ttab.toolApprovalMode = mode\n\ttab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)\n\tctrl := tab.Ctrl\n\ttabIDForSave := tab.ID\n\ta.mu.Unlock()\n\tapplyTabToolApprovalModeToController(ctrl, mode)\n\ta.mu.Lock()\n\tif a.tabs[tabIDForSave] == tab {\n\t\ta.saveTabsLocked()\n\t}\n\ta.mu.Unlock()\n}\n\n// CommandInfo describes one available slash command for the composer's \"/\" menu.\ntype CommandInfo struct {\n\tName string `json:\"name\"` // without the leading slash\n\tDescription string `json:\"description\"`\n\tHint string `json:\"hint,omitempty\"` // argument hint, if any\n\tKind string `json:\"kind\"` // \"builtin\" | \"custom\" | \"mcp\"\n}\n\n// Commands lists the slash commands available this session — built-in actions,\n// custom commands (.reasonix/commands), and MCP prompts — for the composer's \"/\"\n// autocomplete menu.\nfunc (a *App) Commands() []CommandInfo {\n\tout := []CommandInfo{\n\t\t{Name: \"new\", Description: i18n.M.CmdNew, Kind: \"builtin\"},\n\t\t{Name: \"clear\", Description: i18n.M.CmdClear, Kind: \"builtin\"},\n\t\t{Name: \"compact\", Description: i18n.M.CmdCompact, Kind: \"builtin\"},\n\t\t{Name: \"model\", Description: i18n.M.CmdModel, Kind: \"builtin\"},\n\t\t{Name: \"provider\", Description: i18n.M.CmdProvider, Kind: \"builtin\"},\n\t\t{Name: \"effort\", Description: i18n.M.CmdEffort, Kind: \"builtin\"},\n\t\t{Name: \"memory\", Description: i18n.M.CmdMemory, Kind: \"builtin\"},\n\t\t{Name: \"migrate\", Description: i18n.M.CmdMigrate, Kind: \"builtin\"},\n\t\t{Name: \"goal\", Description: i18n.M.CmdGoal, Kind: \"builtin\"},\n\t\t{Name: \"remember\", Description: i18n.M.CmdRemember, Kind: \"builtin\"},\n\t\t{Name: \"mcp\", Description: i18n.M.CmdMcp, Kind: \"builtin\"},\n\t\t{Name: \"hooks\", Description: i18n.M.CmdHooks, Kind: \"builtin\"},\n\t\t{Name: \"theme\", Description: i18n.M.CmdTheme, Kind: \"builtin\"},\n\t\t{Name: \"skill\", Description: i18n.M.CmdSkill, Kind: \"builtin\"},\n\t}\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\t// Skills are invocable as / (the model runs inline ones; subagent ones\n\t// run isolated). Listing them here is what surfaces /init, /explore, … in the\n\t// composer's slash menu; selecting one submits \"/\", which the controller\n\t// resolves via RunSkill.\n\tfor _, s := range ctrl.Skills() {\n\t\tout = append(out, CommandInfo{Name: s.Name, Description: s.Description, Kind: \"skill\"})\n\t}\n\tfor _, c := range ctrl.Commands() {\n\t\tout = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: \"custom\"})\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, p := range h.Prompts() {\n\t\t\tout = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: \"mcp\"})\n\t\t}\n\t}\n\treturn out\n}\n\n// SlashArgItem is one sub-command / argument suggestion for the composer's slash\n// menu (the part after the command word). Mirrors the CLI's arg completion via\n// the shared control.SlashArgItems, so desktop and CLI offer the same hints.\ntype SlashArgItem struct {\n\tLabel string `json:\"label\"`\n\tInsert string `json:\"insert\"`\n\tHint string `json:\"hint\"`\n\tDescend bool `json:\"descend\"`\n}\n\n// SlashArgsResult carries the suggestions plus the byte offset in the input where\n// the current token begins, so the composer replaces just that token.\ntype SlashArgsResult struct {\n\tItems []SlashArgItem `json:\"items\"`\n\tFrom int `json:\"from\"`\n}\n\n// SlashArgs completes the arguments of a management slash command (/mcp, /model,\n// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty\n// Items means the input has no structured arguments to complete.\nfunc (a *App) SlashArgs(input string) SlashArgsResult {\n\ta.mu.RLock()\n\tctrl := a.activeCtrlLocked()\n\tmodel := \"\"\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\tmodel = tab.model\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn SlashArgsResult{Items: []SlashArgItem{}}\n\t}\n\tdata := control.ArgData{\n\t\tSkills: ctrl.Skills(),\n\t\tDisabledSkills: ctrl.DisabledSkills(),\n\t\tConfiguredMCP: ctrl.ConfiguredMCPNames(),\n\t\tDisconnectedMCP: ctrl.DisconnectedMCPNames(),\n\t\tCurrentModel: model,\n\t}\n\tseen := map[string]bool{}\n\tfor _, m := range a.Models() {\n\t\tdata.ModelRefs = append(data.ModelRefs, m.Ref)\n\t\tif m.Provider != \"\" && !seen[m.Provider] {\n\t\t\tseen[m.Provider] = true\n\t\t\tdata.ProviderNames = append(data.ProviderNames, m.Provider)\n\t\t}\n\t\tif m.Current {\n\t\t\tdata.CurrentProvider = m.Provider\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tdata.ServerNames = h.ServerNames()\n\t}\n\titems, from := control.SlashArgItems(input, data)\n\t// Non-nil so it serializes as a JSON array, never null — the frontend filters\n\t// over it directly.\n\tout := SlashArgsResult{Items: []SlashArgItem{}, From: from}\n\tfor _, it := range items {\n\t\tout.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})\n\t}\n\treturn out\n}\n\n// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP\n// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.\ntype CapabilitiesView struct {\n\tServers []ServerView `json:\"servers\"`\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// SkillsSettingsView is the skills management page's data, split from MCP\n// status so opening MCP settings does not scan skill roots.\ntype SkillsSettingsView struct {\n\tSkills []SkillView `json:\"skills\"`\n\tSkillRoots []SkillRootView `json:\"skillRoots\"`\n}\n\n// ServerView is one MCP server for the drawer. Status is \"connected\" (with\n// tool/prompt/resource counts), \"deferred\" (lazy/on-demand startup enabled),\n// \"failed\" (with the connection error), \"initializing\" (background startup in\n// progress), or \"disabled\".\ntype ServerView struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tStatus string `json:\"status\"`\n\tBuiltIn bool `json:\"builtIn,omitempty\"`\n\tConfigured bool `json:\"configured,omitempty\"`\n\tAutoStart bool `json:\"autoStart\"`\n\tTier string `json:\"tier,omitempty\"`\n\tCommand string `json:\"command,omitempty\"`\n\tArgs []string `json:\"args,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tEnvKeys []string `json:\"envKeys,omitempty\"`\n\tTools int `json:\"tools\"`\n\tPrompts int `json:\"prompts\"`\n\tResources int `json:\"resources\"`\n\tError string `json:\"error,omitempty\"`\n\tToolList []ToolView `json:\"toolList,omitempty\"`\n\tAuthStatus string `json:\"authStatus,omitempty\"`\n\tAuthURL string `json:\"authUrl,omitempty\"`\n\tAuthConfigured bool `json:\"authConfigured,omitempty\"`\n}\n\ntype ToolView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\n// SkillView is one discoverable skill for the drawer.\ntype SkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n\tEnabled bool `json:\"enabled\"`\n}\n\ntype SkillRootSkillView struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tScope string `json:\"scope\"`\n\tRunAs string `json:\"runAs\"`\n}\n\n// SkillRootView is one skill discovery root for the drawer's Sources section.\ntype SkillRootView struct {\n\tDir string `json:\"dir\"`\n\tScope string `json:\"scope\"`\n\tPriority int `json:\"priority\"`\n\tStatus string `json:\"status\"`\n\tConfigured bool `json:\"configured\"`\n\tRemovable bool `json:\"removable\"`\n\tSkills int `json:\"skills\"`\n\tSkillItems []SkillRootSkillView `json:\"skillItems,omitempty\"`\n\tWarning string `json:\"warning,omitempty\"`\n}\n\n// Capabilities projects the session's MCP servers (connected + failed) and skills\n// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.\nfunc (a *App) Capabilities() CapabilitiesView {\n\tskills := a.SkillsSettings()\n\treturn CapabilitiesView{\n\t\tServers: a.MCPServers(),\n\t\tSkills: skills.Skills,\n\t\tSkillRoots: skills.SkillRoots,\n\t}\n}\n\n// MCPServers returns only MCP server status for settings pages that do not need\n// skill discovery.\nfunc (a *App) MCPServers() []ServerView {\n\treturn a.mcpServersView()\n}\n\n// SkillsSettings returns the skills management snapshot without MCP status.\nfunc (a *App) SkillsSettings() SkillsSettingsView {\n\tout := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tvar ctrl *control.Controller\n\tif tab != nil {\n\t\tctrl = tab.Ctrl\n\t}\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\n\tdisabled := map[string]bool{}\n\tif cfg, err := config.Load(); err == nil {\n\t\tfor _, name := range cfg.Skills.DisabledSkills {\n\t\t\tif key := config.SkillNameKey(name); key != \"\" {\n\t\t\t\tdisabled[key] = true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, s := range ctrl.AllSkills() {\n\t\tout.Skills = append(out.Skills, SkillView{\n\t\t\tName: s.Name, Description: s.Description,\n\t\t\tScope: string(s.Scope), RunAs: string(s.RunAs),\n\t\t\tEnabled: !disabled[config.SkillNameKey(s.Name)],\n\t\t})\n\t}\n\tout.SkillRoots = a.cachedSkillRootsView()\n\treturn out\n}\n\nfunc (a *App) mcpServersView() []ServerView {\n\tout := []ServerView{}\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tif tab == nil {\n\t\ta.mu.RUnlock()\n\t\treturn out\n\t}\n\tctrl := tab.Ctrl\n\tdisabled := make(map[string]ServerView, len(tab.disabledMCP))\n\tfor name, s := range tab.disabledMCP {\n\t\tdisabled[name] = s\n\t}\n\torder := append([]string(nil), tab.mcpOrder...)\n\tworkspaceRoot := tab.WorkspaceRoot\n\ttabID := tab.ID\n\ta.mu.RUnlock()\n\tif ctrl == nil {\n\t\treturn out\n\t}\n\tseen := map[string]bool{}\n\tconnected := map[string]bool{}\n\tretainedDisabled := map[string]ServerView{}\n\tconfigured := map[string]config.PluginEntry{}\n\tvar configuredEntries []config.PluginEntry\n\tif cfg, err := config.LoadForRoot(workspaceRoot); err == nil {\n\t\tconfiguredEntries = append(configuredEntries, cfg.Plugins...)\n\t\tfor _, p := range configuredEntries {\n\t\t\tconfigured[p.Name] = p\n\t\t}\n\t}\n\tif h := ctrl.Host(); h != nil {\n\t\tfor _, s := range h.Servers() {\n\t\t\tif disabledView, ok := disabled[s.Name]; ok {\n\t\t\t\tdisabledView.Status = \"disabled\"\n\t\t\t\tdisabledView.Error = \"\"\n\t\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\t\tdisabledView = withPluginConfig(disabledView, p)\n\t\t\t\t}\n\t\t\t\tout = append(out, disabledView)\n\t\t\t\tretainedDisabled[s.Name] = disabledView\n\t\t\t\tseen[s.Name] = true\n\t\t\t\tdelete(disabled, s.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[s.Name] = true\n\t\t\tconnected[s.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}\n\t\t\tif p, ok := configured[s.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t\tfor _, f := range h.Failures() {\n\t\t\tseen[f.Name] = true\n\t\t\tview := ServerView{\n\t\t\t\tName: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error,\n\t\t\t}\n\t\t\tif p, ok := configured[f.Name]; ok {\n\t\t\t\tview = withPluginConfig(view, p)\n\t\t\t}\n\t\t\tout = append(out, view)\n\t\t}\n\t}\n\t// Configured servers that are neither connected nor failed are either lazy\n\t// (deferred), background/eager (initializing), or toggled off this session.\n\tif len(configuredEntries) > 0 {\n\t\tfor _, p := range configuredEntries {\n\t\t\tif seen[p.Name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := disabled[p.Name]; ok {\n\t\t\t\ts.Status = \"disabled\"\n\t\t\t\ts = withPluginConfig(s, p)\n\t\t\t\ts.Error = \"\"\n\t\t\t\tout = append(out, s)\n\t\t\t\tretainedDisabled[p.Name] = s\n\t\t\t\tseen[p.Name] = true\n\t\t\t\tdelete(disabled, p.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstatus := \"disabled\"\n\t\t\tif p.ShouldAutoStart() {\n\t\t\t\tswitch p.ResolvedTier() {\n\t\t\t\tcase \"background\", \"eager\":\n\t\t\t\t\tstatus = \"initializing\"\n\t\t\t\tdefault:\n\t\t\t\t\tstatus = \"deferred\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status}, p))\n\t\t\tseen[p.Name] = true\n\t\t}\n\t}\n\tout = orderServerViews(out, order)\n\n\ta.mu.Lock()\n\tif tab, ok := a.tabs[tabID]; ok {\n\t\tfor name := range connected {\n\t\t\tdelete(retainedDisabled, name)\n\t\t}\n\t\ttab.disabledMCP = retainedDisabled\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)\n\t}\n\ta.mu.Unlock()\n\treturn out\n}\n\nfunc withPluginConfig(v ServerView, p config.PluginEntry) ServerView {\n\ttt := p.Type\n\tif tt == \"\" {\n\t\ttt = \"stdio\"\n\t}\n\tv.Transport = tt\n\tv.Configured = true\n\tv.AutoStart = p.ShouldAutoStart()\n\tv.Tier = p.ResolvedTier()\n\tv.Command = p.Command\n\tv.Args = append([]string(nil), p.Args...)\n\tv.URL = p.URL\n\tv.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)\n\tif len(p.Env) > 0 {\n\t\tv.EnvKeys = make([]string, 0, len(p.Env))\n\t\tfor k := range p.Env {\n\t\t\tv.EnvKeys = append(v.EnvKeys, k)\n\t\t}\n\t\tsort.Strings(v.EnvKeys)\n\t}\n\tauth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)\n\tv.AuthStatus = auth.Status\n\tv.AuthURL = auth.URL\n\treturn v\n}\n\nconst skillRootsCacheTTL = 10 * time.Second\n\nfunc (a *App) cachedSkillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\tkey := skillRootsCacheKey(cwd, cfg, userCfg)\n\n\tnow := time.Now()\n\ta.skillRootsMu.Lock()\n\tif a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {\n\t\troots := cloneSkillRootViews(a.skillRootsCache.roots)\n\t\ta.skillRootsMu.Unlock()\n\t\treturn roots\n\t}\n\ta.skillRootsMu.Unlock()\n\n\troots := skillRootsViewFrom(cwd, cfg, userCfg)\n\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{\n\t\tkey: key,\n\t\tat: now,\n\t\troots: cloneSkillRootViews(roots),\n\t}\n\ta.skillRootsMu.Unlock()\n\treturn roots\n}\n\nfunc (a *App) invalidateSkillRootsCache() {\n\ta.skillRootsMu.Lock()\n\ta.skillRootsCache = skillRootsCache{}\n\ta.skillRootsMu.Unlock()\n}\n\nfunc skillRootsView() []SkillRootView {\n\tcwd, _ := os.Getwd()\n\tcfg, _ := config.Load()\n\tuserCfg := config.LoadForEdit(config.UserConfigPath())\n\treturn skillRootsViewFrom(cwd, cfg, userCfg)\n}\n\nfunc skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {\n\tvar custom []string\n\tvar excluded []string\n\tmaxDepth := 3\n\tif cfg != nil {\n\t\tcustom = cfg.SkillCustomPaths()\n\t\texcluded = cfg.SkillExcludedPaths()\n\t\tmaxDepth = cfg.SkillMaxDepth()\n\t}\n\tst := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})\n\tcounts := map[string]int{}\n\tskillItems := map[string][]SkillRootSkillView{}\n\troots := st.Roots()\n\tfor _, sk := range st.List() {\n\t\troot := skillDisplayRoot(sk, roots)\n\t\tcounts[root]++\n\t\tskillItems[root] = append(skillItems[root], SkillRootSkillView{\n\t\t\tName: sk.Name,\n\t\t\tDescription: sk.Description,\n\t\t\tScope: string(sk.Scope),\n\t\t\tRunAs: string(sk.RunAs),\n\t\t})\n\t}\n\tfor root := range skillItems {\n\t\tsort.Slice(skillItems[root], func(i, j int) bool {\n\t\t\treturn skillItems[root][i].Name < skillItems[root][j].Name\n\t\t})\n\t}\n\tuserConfigured := map[string]bool{}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tuserConfigured[config.CanonicalSkillPath(p)] = true\n\t\t}\n\t}\n\tout := []SkillRootView{}\n\tfor _, r := range roots {\n\t\tdir := config.CanonicalSkillPath(r.Dir)\n\t\tview := SkillRootView{\n\t\t\tDir: r.Dir,\n\t\t\tScope: string(r.Scope),\n\t\t\tPriority: r.Priority + 1,\n\t\t\tStatus: string(r.Status),\n\t\t\tConfigured: r.Scope == skill.ScopeCustom && userConfigured[dir],\n\t\t\tRemovable: true,\n\t\t\tSkills: counts[dir],\n\t\t\tSkillItems: skillItems[dir],\n\t\t}\n\t\tout = append(out, view)\n\t}\n\tif userCfg != nil {\n\t\tfor _, p := range userCfg.Skills.Paths {\n\t\t\tif rootActive(out, p) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, SkillRootView{\n\t\t\t\tDir: p,\n\t\t\t\tScope: string(skill.ScopeCustom),\n\t\t\t\tStatus: \"inactive\",\n\t\t\t\tConfigured: true,\n\t\t\t\tRemovable: true,\n\t\t\t\tWarning: \"configured in user config but not active in this workspace; project [skills].paths may override it\",\n\t\t\t})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {\n\ttype cacheKey struct {\n\t\tCWD string `json:\"cwd\"`\n\t\tCustom []string `json:\"custom\"`\n\t\tExcluded []string `json:\"excluded\"`\n\t\tMaxDepth int `json:\"maxDepth\"`\n\t\tUserPaths []string `json:\"userPaths\"`\n\t}\n\tkey := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}\n\tif cfg != nil {\n\t\tkey.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())\n\t\tkey.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())\n\t\tkey.MaxDepth = cfg.SkillMaxDepth()\n\t}\n\tif userCfg != nil {\n\t\tkey.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)\n\t}\n\tb, err := json.Marshal(key)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s|%v|%v|%d|%v\", key.CWD, key.Custom, key.Excluded, key.MaxDepth, key.UserPaths)\n\t}\n\treturn string(b)\n}\n\nfunc canonicalSkillPaths(paths []string) []string {\n\tout := make([]string, 0, len(paths))\n\tfor _, p := range paths {\n\t\tout = append(out, config.CanonicalSkillPath(p))\n\t}\n\tsort.Strings(out)\n\treturn out\n}\n\nfunc cloneSkillRootViews(in []SkillRootView) []SkillRootView {\n\tout := make([]SkillRootView, len(in))\n\tfor i, r := range in {\n\t\tout[i] = r\n\t\tout[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)\n\t}\n\treturn out\n}\n\nfunc rootActive(roots []SkillRootView, path string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tfor _, r := range roots {\n\t\tif r.Scope == string(skill.ScopeCustom) && config.CanonicalSkillPath(r.Dir) == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PickSkillFolder opens a directory picker for adding custom skill roots. It only\n// returns a path; AddSkillPath performs normalization and writes config.\nfunc (a *App) PickSkillFolder() (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tcur, _ := os.Getwd()\n\tdir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{\n\t\tTitle: \"Choose skills folder\",\n\t\tDefaultDirectory: dialogDefaultDirectory(cur),\n\t})\n\tif err != nil || dir == \"\" {\n\t\treturn \"\", err\n\t}\n\treturn normalizeSkillPath(dir), nil\n}\n\n// AddSkillPath adds a custom skill root to the user config and rebuilds the\n// controller so the skills index and slash menu reflect it immediately.\nfunc (a *App) AddSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\tworkspaceRoot := a.activeWorkspaceRoot()\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tif isConventionSkillRoot(path, workspaceRoot) {\n\t\t\treturn c.RestoreSkillPath(path)\n\t\t}\n\t\treturn c.AddSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RemoveSkillPath removes a skill source from the user config and rebuilds. For\n// convention roots, it records a pseudo-delete in excluded_paths.\nfunc (a *App) RemoveSkillPath(path string) error {\n\tpath = normalizeSkillPath(path)\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\tremoved, err := c.RemoveSkillPath(path)\n\t\tif err != nil || removed {\n\t\t\treturn err\n\t\t}\n\t\treturn c.ExcludeSkillPath(path)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\n// RefreshSkills rebuilds the controller without changing config, reloading skill\n// discovery, the system prompt index, and slash completions.\nfunc (a *App) RefreshSkills() error {\n\ta.invalidateSkillRootsCache()\n\treturn a.rebuild()\n}\n\n// SetSkillEnabled persists a skill toggle and rebuilds the controller so the\n// prompt index, slash menu, and skill tools reflect it immediately.\nfunc (a *App) SetSkillEnabled(name string, enabled bool) error {\n\terr := a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetSkillEnabled(name, enabled)\n\t})\n\tif err == nil {\n\t\ta.invalidateSkillRootsCache()\n\t}\n\treturn err\n}\n\nfunc normalizeSkillPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path == \"~\" || strings.HasPrefix(path, \"~/\") || strings.HasPrefix(path, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil {\n\t\t\tif path == \"~\" {\n\t\t\t\tpath = home\n\t\t\t} else {\n\t\t\t\tpath = filepath.Join(home, path[2:])\n\t\t\t}\n\t\t}\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn filepath.Clean(path)\n\t}\n\tif info.Mode().IsRegular() {\n\t\tif filepath.Base(path) == skill.SkillFile {\n\t\t\treturn filepath.Clean(filepath.Dir(filepath.Dir(path)))\n\t\t}\n\t\treturn filepath.Clean(filepath.Dir(path))\n\t}\n\tif info.IsDir() {\n\t\tif _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {\n\t\t\treturn filepath.Clean(filepath.Dir(path))\n\t\t}\n\t}\n\treturn filepath.Clean(path)\n}\n\nfunc isConventionSkillRoot(path, workspaceRoot string) bool {\n\twant := config.CanonicalSkillPath(path)\n\tif want == \"\" {\n\t\treturn false\n\t}\n\tbases := []string{workspaceRoot}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tbases = append(bases, home)\n\t}\n\tfor _, base := range bases {\n\t\tbase = strings.TrimSpace(base)\n\t\tif base == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, dir := range config.ConventionDirs {\n\t\t\tif want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc skillRootPath(path string) string {\n\tif filepath.Base(path) == skill.SkillFile {\n\t\treturn filepath.Dir(path)\n\t}\n\treturn path\n}\n\nfunc skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {\n\tcleanPath := filepath.Clean(sk.Path)\n\tfor _, r := range roots {\n\t\tif r.Scope != sk.Scope {\n\t\t\tcontinue\n\t\t}\n\t\tcleanRoot := filepath.Clean(r.Dir)\n\t\tprefix := cleanRoot + string(filepath.Separator)\n\t\tif cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {\n\t\t\treturn config.CanonicalSkillPath(r.Dir)\n\t\t}\n\t}\n\treturn config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))\n}\n\n// MCPServerInput is the drawer's \"add server\" form. Transport is \"stdio\" (Command\n// + Args + Env) or \"http\"/\"sse\" (URL). Mirrors config.PluginEntry's writable shape.\ntype MCPServerInput struct {\n\tName string `json:\"name\"`\n\tTransport string `json:\"transport\"`\n\tCommand string `json:\"command\"`\n\tArgs []string `json:\"args\"`\n\tURL string `json:\"url\"`\n\tEnv map[string]string `json:\"env\"`\n}\n\n// AddMCPServer connects a server live and persists it to config (Customize → MCP →\n// Add). Returns the number of tools it exposed.\nfunc (a *App) AddMCPServer(in MCPServerInput) (int, error) {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn 0, rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tentry := config.PluginEntry{\n\t\tName: in.Name,\n\t\tType: normalizeMCPTransport(in.Transport),\n\t\tCommand: in.Command,\n\t\tArgs: in.Args,\n\t\tURL: in.URL,\n\t\tEnv: in.Env,\n\t}\n\tentry, _ = config.NormalizePluginCommandLine(entry)\n\tif err := a.saveDesktopMCPServer(entry); err != nil {\n\t\treturn 0, err\n\t}\n\treturn ctrl.ConnectMCPServer(entry)\n}\n\n// UpdateMCPServer edits a persisted external MCP server. The name is the stable\n// identity; callers must remove + add if they want to rename a server.\nfunc (a *App) UpdateMCPServer(name string, in MCPServerInput) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif strings.TrimSpace(in.Name) != \"\" && strings.TrimSpace(in.Name) != name {\n\t\treturn fmt.Errorf(\"renaming MCP servers is not supported; remove and add a new server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Type = normalizeMCPTransport(in.Transport)\n\tupdated.Command = strings.TrimSpace(in.Command)\n\tupdated.Args = append([]string(nil), in.Args...)\n\tupdated.URL = strings.TrimSpace(in.URL)\n\tupdated.Tier = \"\"\n\tif in.Env != nil {\n\t\tupdated.Env = in.Env\n\t}\n\tupdated, _ = config.NormalizePluginCommandLine(updated)\n\tif updated.Type == \"stdio\" {\n\t\tupdated.URL = \"\"\n\t} else {\n\t\tupdated.Command = \"\"\n\t\tupdated.Args = nil\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\n\ta.mu.RLock()\n\ttab := a.activeTabLocked()\n\tsessionDisabled := false\n\tif tab != nil {\n\t\t_, sessionDisabled = tab.disabledMCP[name]\n\t}\n\ta.mu.RUnlock()\n\twasConnected := mcpConnected(ctrl, name)\n\twasFailed := mcpFailed(ctrl, name)\n\tif wasConnected {\n\t\tctrl.DisconnectMCPServer(name)\n\t}\n\tif !sessionDisabled && (wasConnected || wasFailed || updated.ResolvedTier() != \"lazy\") {\n\t\tif _, err := ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\n// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).\nfunc (a *App) RemoveMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tdisconnected := tab.Ctrl.DisconnectMCPServer(name)\n\tremoved, err := a.removeDesktopMCPServer(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif disconnected || removed {\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ttab.mcpOrder = removeServerOrder(tab.mcpOrder, name)\n\t\ta.mu.Unlock()\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"no MCP server named %q\", name)\n}\n\n// ReconnectMCPServer disconnects the server if it is already connected (to force\n// a fresh handshake and tool re-registration), then reconnects. Failures are\n// recorded on the Host so the UI can render them.\nfunc (a *App) ReconnectMCPServer(name string) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\ttab.Ctrl.DisconnectMCPServer(name)\n\tif h := tab.Ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\tif err != nil {\n\t\tif plugin.IsServerAlreadyConnected(err) {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t\treturn nil\n\t\t}\n\t\tentry := config.PluginEntry{Name: name}\n\t\tif p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {\n\t\t\tentry = p\n\t\t}\n\t\trecordMCPFailure(tab.Ctrl, entry, err)\n\t\treturn err\n\t}\n\ta.mu.Lock()\n\tdelete(tab.disabledMCP, name)\n\ta.mu.Unlock()\n\treturn nil\n}\n\n// ClearMCPServerAuthentication removes local auth-like config for one MCP and\n// clears the current session's cached connection failure. It does not remove the\n// server itself or try to sign the user out of the third-party browser session.\nfunc (a *App) ClearMCPServerAuthentication(name string) error {\n\tctrl := a.activeCtrl()\n\tif ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tif _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {\n\t\treturn err\n\t}\n\tctrl.DisconnectMCPServer(name)\n\tif h := ctrl.Host(); h != nil {\n\t\th.ClearFailure(name)\n\t}\n\treturn nil\n}\n\n// SetMCPServerEnabled is the connector toggle: on reconnects a configured server\n// for this session, off disconnects it (config untouched either way — like Claude\n// Code's per-conversation enable/disable, it resets on the next session start).\nfunc (a *App) SetMCPServerEnabled(name string, enabled bool) error {\n\ttab := a.activeTab()\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn fmt.Errorf(\"no active session\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tconfiguredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif enabled {\n\t\t_, err := a.connectConfiguredMCPServerForTab(tab, name)\n\t\tif err == nil {\n\t\t\ta.mu.Lock()\n\t\t\tdelete(tab.disabledMCP, name)\n\t\t\ta.mu.Unlock()\n\t\t}\n\t\treturn err\n\t}\n\tif s, ok := findMCPServerView(tab.Ctrl, name); ok {\n\t\ts.Status = \"disabled\"\n\t\ts.Error = \"\"\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t} else if hasConfiguredEntry {\n\t\ts := withPluginConfig(ServerView{Name: name, Status: \"disabled\"}, configuredEntry)\n\t\ta.mu.Lock()\n\t\tif tab.disabledMCP == nil {\n\t\t\ttab.disabledMCP = map[string]ServerView{}\n\t\t}\n\t\ttab.disabledMCP[name] = s\n\t\ttab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})\n\t\ta.mu.Unlock()\n\t}\n\tif tab.SharedHostKey != \"\" {\n\t\ttab.Ctrl.UnregisterMCPServerTools(name)\n\t} else {\n\t\ttab.Ctrl.DisconnectMCPServer(name)\n\t}\n\treturn nil\n}\n\nfunc (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {\n\tif tab == nil || tab.Ctrl == nil {\n\t\treturn 0, fmt.Errorf(\"no active session\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfor _, p := range cfg.Plugins {\n\t\tif p.Name == name {\n\t\t\treturn tab.Ctrl.ConnectMCPServer(p)\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no configured MCP server named %q\", name)\n}\n\n// SetMCPServerTier is kept for old desktop bindings. New config writes drop the\n// retired tier field.\nfunc (a *App) SetMCPServerTier(name, tier string) error {\n\ttier = normalizeMCPTier(tier)\n\ttab := a.activeTab()\n\tif tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"MCP server\")\n\t}\n\tupdated, found, err := a.desktopMCPServerForEdit(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !found {\n\t\treturn fmt.Errorf(\"no configured MCP server named %q\", name)\n\t}\n\tupdated.Tier = tier\n\tif !updated.ShouldAutoStart() {\n\t\ton := true\n\t\tupdated.AutoStart = &on\n\t}\n\tif err := a.saveDesktopMCPServer(updated); err != nil {\n\t\treturn err\n\t}\n\tif tier != \"lazy\" && tab != nil && tab.Ctrl != nil && !mcpConnected(tab.Ctrl, name) {\n\t\tif _, err := tab.Ctrl.ConnectMCPServer(updated); err != nil {\n\t\t\trecordMCPFailure(tab.Ctrl, updated, err)\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.Lock()\n\t\tdelete(tab.disabledMCP, name)\n\t\ta.mu.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn config.PluginEntry{}, false, err\n\t}\n\tif p, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\treturn p, true, nil\n\t}\n\tif merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {\n\t\tif p, ok := findPluginEntry(merged.Plugins, name); ok {\n\t\t\treturn p, true, nil\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false, nil\n}\n\nfunc (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {\n\tif a.desktopMCPServerOwnedByProjectMCPJSON(entry.Name) {\n\t\t_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), entry)\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.UpsertPlugin(entry); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\t_, err = a.removeProjectMCPOverride(entry.Name)\n\treturn err\n}\n\nfunc (a *App) removeDesktopMCPServer(name string) (bool, error) {\n\tremoved := false\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif cfg.RemovePlugin(name) {\n\t\tremoved = true\n\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tprojectRemoved, err := a.removeProjectMCPOverride(name)\n\tif err != nil {\n\t\treturn removed, err\n\t}\n\tmcpJSONRemoved, err := a.removeProjectMCPJSONServer(name)\n\tif err != nil {\n\t\treturn removed || projectRemoved, err\n\t}\n\treturn removed || projectRemoved || mcpJSONRemoved, nil\n}\n\nfunc (a *App) removeProjectMCPOverride(name string) (bool, error) {\n\tpath := projectConfigPathForRoot(a.activeWorkspaceRoot())\n\tuserPath := config.UserConfigPath()\n\tif path == \"\" || sameConfigPath(path, userPath) {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tcfg := config.LoadForEdit(path)\n\tif !cfg.RemovePlugin(name) {\n\t\treturn false, nil\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (a *App) removeProjectMCPJSONServer(name string) (bool, error) {\n\treturn config.RemoveMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n}\n\nfunc (a *App) desktopMCPServerOwnedByProjectMCPJSON(name string) bool {\n\tif strings.TrimSpace(name) == \"\" {\n\t\treturn false\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err == nil {\n\t\tif _, ok := findPluginEntry(cfg.Plugins, name); ok {\n\t\t\treturn false\n\t\t}\n\t}\n\tprojectCfg := config.LoadForEdit(projectConfigPathForRoot(a.activeWorkspaceRoot()))\n\tif _, ok := findPluginEntry(projectCfg.Plugins, name); ok {\n\t\treturn false\n\t}\n\t_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(a.activeWorkspaceRoot()), name)\n\treturn err == nil && ok\n}\n\nfunc projectMCPJSONPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \".mcp.json\"\n\t}\n\treturn filepath.Join(root, \".mcp.json\")\n}\n\nfunc findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {\n\tfor _, p := range entries {\n\t\tif p.Name == name {\n\t\t\treturn p, true\n\t\t}\n\t}\n\treturn config.PluginEntry{}, false\n}\n\nfunc normalizeMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc normalizeMCPTransport(transport string) string {\n\tswitch strings.ToLower(strings.TrimSpace(transport)) {\n\tcase \"http\", \"streamable-http\":\n\t\treturn \"http\"\n\tcase \"sse\":\n\t\treturn \"sse\"\n\tdefault:\n\t\treturn \"stdio\"\n\t}\n}\n\nfunc mcpConnected(ctrl *control.Controller, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc mcpFailed(ctrl *control.Controller, name string) bool {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn false\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc recordMCPFailure(ctrl *control.Controller, e config.PluginEntry, err error) {\n\tif ctrl == nil || ctrl.Host() == nil || err == nil {\n\t\treturn\n\t}\n\texp := e.ExpandedPlugin()\n\tctrl.Host().RecordFailure(plugin.Spec{\n\t\tName: exp.Name,\n\t\tType: exp.Type,\n\t\tCommand: exp.Command,\n\t\tArgs: exp.Args,\n\t\tEnv: exp.Env,\n\t\tURL: exp.URL,\n\t\tHeaders: exp.Headers,\n\t}, err)\n}\n\nfunc findMCPServerView(ctrl *control.Controller, name string) (ServerView, bool) {\n\tif ctrl == nil || ctrl.Host() == nil {\n\t\treturn ServerView{}, false\n\t}\n\tfor _, s := range ctrl.Host().Servers() {\n\t\tif s.Name == name {\n\t\t\treturn ServerView{\n\t\t\t\tName: s.Name, Transport: s.Transport, Status: \"connected\",\n\t\t\t\tTools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,\n\t\t\t\tToolList: pluginToolsToView(s.ToolList),\n\t\t\t}, true\n\t\t}\n\t}\n\tfor _, f := range ctrl.Host().Failures() {\n\t\tif f.Name == name {\n\t\t\treturn ServerView{Name: f.Name, Transport: f.Transport, Status: \"failed\", Error: f.Error}, true\n\t\t}\n\t}\n\treturn ServerView{}, false\n}\n\nfunc pluginToolsToView(tools []plugin.ToolInfo) []ToolView {\n\tif len(tools) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]ToolView, 0, len(tools))\n\tfor _, t := range tools {\n\t\tout = append(out, ToolView{Name: t.Name, Description: t.Description})\n\t}\n\treturn out\n}\n\nfunc orderServerViews(servers []ServerView, order []string) []ServerView {\n\tpos := make(map[string]int, len(order))\n\tfor i, name := range order {\n\t\tpos[name] = i\n\t}\n\tsort.SliceStable(servers, func(i, j int) bool {\n\t\tpi, iok := pos[servers[i].Name]\n\t\tpj, jok := pos[servers[j].Name]\n\t\tswitch {\n\t\tcase iok && jok:\n\t\t\treturn pi < pj\n\t\tcase iok:\n\t\t\treturn true\n\t\tcase jok:\n\t\t\treturn false\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t})\n\treturn servers\n}\n\nfunc mergeServerOrder(order []string, servers []ServerView) []string {\n\tseen := make(map[string]bool, len(order)+len(servers))\n\tnext := make([]string, 0, len(order)+len(servers))\n\tfor _, name := range order {\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tfor _, s := range servers {\n\t\tif s.Name == \"\" || seen[s.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[s.Name] = true\n\t\tnext = append(next, s.Name)\n\t}\n\treturn next\n}\n\nfunc removeServerOrder(order []string, name string) []string {\n\tif name == \"\" || len(order) == 0 {\n\t\treturn order\n\t}\n\tnext := order[:0]\n\tfor _, n := range order {\n\t\tif n != name {\n\t\t\tnext = append(next, n)\n\t\t}\n\t}\n\treturn next\n}\n\n// ModelInfo is one (provider, model) the bottom switcher can pick. Ref (\"provider/\n// model\") is what SetModel takes; Provider/Model are for display.\ntype ModelInfo struct {\n\tRef string `json:\"ref\"`\n\tProvider string `json:\"provider\"`\n\tModel string `json:\"model\"`\n\tCurrent bool `json:\"current\"`\n}\n\ntype EffortInfo struct {\n\tSupported bool `json:\"supported\"`\n\tCurrent string `json:\"current\"`\n\tDefault string `json:\"default\"`\n\tLevels []string `json:\"levels\"`\n}\n\n// Models flattens the configured providers into their (provider, model) pairs —\n// the switcher's options — marking the active one. A vendor with a `models` list\n// yields one entry per model, all sharing the same endpoint/key. Unconfigured\n// providers are skipped. Result is non-nil: the frontend reads .length, so a nil\n// slice (JSON null) would crash the switcher on an empty list.\nfunc (a *App) Models() []ModelInfo {\n\treturn a.ModelsForTab(\"\")\n}\n\nfunc (a *App) ModelsForTab(tabID string) []ModelInfo {\n\ta.mu.RLock()\n\tcurModel := \"\"\n\tworkspaceRoot := \"\"\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tcurModel = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn []ModelInfo{}\n\t}\n\tif entry, ok := cfg.ResolveModel(curModel); ok {\n\t\tcurModel = entry.Name + \"/\" + entry.Model\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tout := []ModelInfo{}\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tif !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, m := range p.ChatModelList() {\n\t\t\tref := p.Name + \"/\" + m\n\t\t\tout = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})\n\t\t}\n\t}\n\treturn out\n}\n\nfunc modelProviderAccessAllowed(access map[string]bool, name string) bool {\n\tif len(access) == 0 {\n\t\treturn true\n\t}\n\treturn access[strings.TrimSpace(name)]\n}\n\nfunc controllerHasActiveRuntimeWork(ctrl *control.Controller) bool {\n\tif ctrl == nil {\n\t\treturn false\n\t}\n\tstatus := ctrl.RuntimeStatus()\n\treturn status.Running || status.PendingPrompt || status.BackgroundJobs > 0\n}\n\nfunc rebuildControllerActiveWorkError(setting string) error {\n\treturn fmt.Errorf(\"finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s\", setting)\n}\n\n// SetModel switches the active model and carries the current conversation into the\n// new model's session, so the chat continues seamlessly and subsequent turns use\n// the new model. No-op if name is already active or the controller is down.\nfunc (a *App) SetModel(name string) error {\n\treturn a.SetModelForTab(\"\", name)\n}\n\nfunc (a *App) SetModelForTab(tabID, name string) error {\n\tif a.ctx == nil || name == \"\" {\n\t\treturn nil\n\t}\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\treturn nil\n\t}\n\tif name == tab.model {\n\t\treturn nil\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"model\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn err\n\t}\n\tentry, ok := cfg.ResolveModel(name)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q\", name)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {\n\t\treturn fmt.Errorf(\"model %q is not available because provider %q is not added\", name, entry.Name)\n\t}\n\tname = entry.Name + \"/\" + entry.Model\n\teffortOverride := cloneStringPtr(tab.effort)\n\tif effortOverride != nil {\n\t\tnormalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))\n\t\tif err != nil {\n\t\t\teffortOverride = nil\n\t\t} else {\n\t\t\teffortOverride = &normalized\n\t\t}\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = tab.Ctrl.Snapshot()\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\n\t// Preserve the shared plugin host across controller rebuilds — the tab\n\t// stays in the same workspace root, so MCP processes must not be restarted.\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: name,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(effortOverride),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = name\n\ttab.effort = cloneStringPtr(effortOverride)\n\ttab.Label = newCtrl.Label()\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) Effort() EffortInfo {\n\treturn a.EffortForTab(\"\")\n}\n\nfunc (a *App) EffortForTab(tabID string) EffortInfo {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn EffortInfo{Current: \"auto\", Levels: []string{}}\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\treturn EffortInfo{Supported: false, Current: \"auto\", Default: cap.Default, Levels: []string{}}\n\t}\n\tlevels := cap.Levels\n\tif levels == nil {\n\t\tlevels = []string{}\n\t}\n\treturn EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}\n}\n\nfunc (a *App) SetEffort(level string) error {\n\treturn a.SetEffortForTab(\"\", level)\n}\n\nfunc (a *App) SetEffortForTab(tabID, level string) error {\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\tentry, err := a.currentProviderEntryForTab(\"\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn a.applyProviderEffortConfig(entry, effort)\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"effort\")\n\t}\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmodelRef := entry.Name + \"/\" + entry.Model\n\teffort, err := config.NormalizeEffort(entry, level)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = tab.Ctrl.Snapshot()\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: &effort,\n\t\tTokenMode: currentTabTokenMode(tab),\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.effort = &effort\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) SetTokenMode(mode string) error {\n\treturn a.SetTokenModeForTab(\"\", mode)\n}\n\nfunc (a *App) SetTokenModeForTab(tabID, mode string) error {\n\tmode = boot.NormalizeTokenMode(mode)\n\ttab := a.tabByID(tabID)\n\tif tab == nil {\n\t\tif strings.TrimSpace(tabID) == \"\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"tab %q not found\", tabID)\n\t}\n\tif mode == currentTabTokenMode(tab) {\n\t\treturn nil\n\t}\n\tctrl := tab.Ctrl\n\tif controllerHasActiveRuntimeWork(ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"token mode\")\n\t}\n\tmodelRef, fallback, err := a.resolvedModelForTab(tab)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif fallback && strings.TrimSpace(tab.model) != \"\" {\n\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", tab.model, modelRef))\n\t}\n\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\toldCtrl := tab.Ctrl\n\tif oldCtrl != nil {\n\t\tprevPath = oldCtrl.SessionPath()\n\t\t_ = oldCtrl.Snapshot()\n\t\tcarried = oldCtrl.History()\n\t}\n\tsharedHost := a.lookupSharedHost(tab.SharedHostKey)\n\tnewCtrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: modelRef,\n\t\tRequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: mode,\n\t\tSharedHost: sharedHost,\n\t\tCleanupPendingReconciler: reconcileDesktopCleanupPending,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(newCtrl)\n\tif oldCtrl != nil {\n\t\toldCtrl.Close()\n\t}\n\ta.mu.Lock()\n\ttab.Ctrl = newCtrl\n\ttab.model = modelRef\n\ttab.tokenMode = mode\n\ttab.Label = newCtrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\tnewCtrl.EnableInteractiveApproval()\n\tapplyTabModeToController(newCtrl, tab.mode)\n\tapplyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode)\n\tnewCtrl.SetGoal(tab.goal)\n\tpath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())\n\tif len(carried) > 0 {\n\t\tnewCtrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tnewCtrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {\n\treturn a.applyConfigChange(func(cfg *config.Config) error {\n\t\tif _, ok := cfg.Provider(entry.Name); !ok {\n\t\t\tif err := cfg.UpsertProvider(*entry); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif entry.Kind == \"anthropic\" && effort != \"\" && entry.Thinking == \"\" {\n\t\t\tif err := cfg.SetProviderThinking(entry.Name, \"adaptive\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfor _, name := range providerEffortTargetNames(cfg, entry) {\n\t\t\tif err := cfg.SetProviderEffort(name, effort); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {\n\tif cfg == nil || entry == nil {\n\t\treturn nil\n\t}\n\tout := []string{entry.Name}\n\tseen := map[string]bool{entry.Name: true}\n\tkind := officialProviderKindFromEntry(*entry)\n\tif kind == \"\" {\n\t\treturn out\n\t}\n\tvar family []string\n\tswitch kind {\n\tcase \"deepseek\":\n\t\tfamily = []string{\"deepseek\", \"deepseek-flash\", \"deepseek-pro\"}\n\tcase \"mimo-token-plan\":\n\t\tfamily = []string{\"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\"}\n\tcase \"mimo-api\":\n\t\tfamily = []string{\"mimo-api\"}\n\t}\n\tfor _, name := range family {\n\t\tif seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := cfg.Provider(name)\n\t\tif !ok || officialProviderKindFromEntry(*p) != kind {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// DirEntry is one entry in the \"@\" file-reference menu.\ntype DirEntry struct {\n\tName string `json:\"name\"`\n\tIsDir bool `json:\"isDir\"`\n}\n\n// FilePreview is a bounded, read-only file payload for the workspace side panel.\ntype FilePreview struct {\n\tPath string `json:\"path\"`\n\tBody string `json:\"body\"`\n\tSize int64 `json:\"size\"`\n\tTruncated bool `json:\"truncated\"`\n\tBinary bool `json:\"binary\"`\n\tKind string `json:\"kind,omitempty\"`\n\tMime string `json:\"mime,omitempty\"`\n\tURL string `json:\"url,omitempty\"`\n\tErr string `json:\"err,omitempty\"`\n}\n\ntype WorkspaceChangeView struct {\n\tPath string `json:\"path\"`\n\tOldPath string `json:\"oldPath,omitempty\"`\n\tSources []string `json:\"sources\"`\n\tGitStatus string `json:\"gitStatus,omitempty\"`\n\tTurns []int `json:\"turns,omitempty\"`\n\tLatestPrompt string `json:\"latestPrompt,omitempty\"`\n\tLatestTime int64 `json:\"latestTime,omitempty\"`\n}\n\ntype WorkspaceChangesView struct {\n\tFiles []WorkspaceChangeView `json:\"files\"`\n\tGitAvailable bool `json:\"gitAvailable\"`\n\tGitErr string `json:\"gitErr,omitempty\"`\n\tGitBranch string `json:\"gitBranch,omitempty\"`\n}\n\n// workspaceNoiseNames are local cache/vendor entries hidden from the file tree\n// and \"@\" menu regardless of where they appear.\nvar workspaceNoiseNames = map[string]bool{\n\t\".codex\": true,\n\t\".DS_Store\": true,\n\t\".git\": true,\n\t\".npm\": true,\n\t\".pnpm-store\": true,\n\t\"node_modules\": true,\n\t\"Thumbs.db\": true,\n}\n\nvar workspaceNoiseDirs = map[string]bool{\n\t\"bin\": true,\n\t\"desktop/build\": true,\n\t\"desktop/frontend/dist\": true,\n\t\"desktop/frontend/wailsjs\": true,\n\t\"dist\": true,\n\t\"npm/.stage\": true,\n\t\"site/.astro\": true,\n\t\"site/dist\": true,\n\t\"stage\": true,\n\t\"tmp\": true,\n}\n\nconst filePreviewLimit = 256 * 1024\nconst fileRefSearchLimit = 20\n\nvar previewMediaMIMEs = map[string]string{\n\t\".bmp\": \"image/bmp\",\n\t\".gif\": \"image/gif\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".jpg\": \"image/jpeg\",\n\t\".pdf\": \"application/pdf\",\n\t\".png\": \"image/png\",\n\t\".svg\": \"image/svg+xml\",\n\t\".webp\": \"image/webp\",\n}\n\nfunc trimUTF8PartialSuffix(data []byte) []byte {\n\tif utf8.Valid(data) {\n\t\treturn data\n\t}\n\tfor i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {\n\t\tif !utf8.RuneStart(data[i]) {\n\t\t\tcontinue\n\t\t}\n\t\tif !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {\n\t\t\treturn data\n\t\t}\n\t\treturn data[:i]\n\t}\n\treturn data\n}\n\nfunc previewMediaKind(path string) (kind string, mime string) {\n\tmime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]\n\tif mime == \"\" {\n\t\treturn \"\", \"\"\n\t}\n\tif strings.HasPrefix(mime, \"image/\") {\n\t\treturn \"image\", mime\n\t}\n\tif mime == \"application/pdf\" {\n\t\treturn \"pdf\", mime\n\t}\n\treturn \"\", \"\"\n}\n\nfunc workspaceEntryRel(rel, name string) string {\n\trel = strings.Trim(filepath.ToSlash(rel), \"/\")\n\tif rel == \"\" || rel == \".\" {\n\t\treturn name\n\t}\n\treturn rel + \"/\" + name\n}\n\nfunc skipWorkspaceEntry(rel, name string, isDir bool) bool {\n\tif workspaceNoiseNames[name] {\n\t\treturn true\n\t}\n\treturn isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]\n}\n\nfunc (a *App) activeWorkspaceBase() (string, error) {\n\treturn workspaceBaseFromRoot(a.activeWorkspaceRoot())\n}\n\nfunc workspaceBaseFromRoot(root string) (string, error) {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn os.Getwd()\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Clean(root), nil\n}\n\nfunc (a *App) workspacePath(rel string) (string, bool, error) {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\treturn workspacePathForBase(base, rel)\n}\n\nfunc workspacePathForBase(base, rel string) (string, bool, error) {\n\tbase = filepath.Clean(base)\n\tif rel == \"\" {\n\t\treturn \"\", false, os.ErrInvalid\n\t}\n\tpath := rel\n\tif !filepath.IsAbs(path) {\n\t\tpath = filepath.Join(base, rel)\n\t}\n\tpath = filepath.Clean(path)\n\tr, err := filepath.Rel(base, path)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tif r == \"..\" || strings.HasPrefix(r, \"..\"+string(os.PathSeparator)) {\n\t\treturn \"\", false, os.ErrPermission\n\t}\n\treturn path, true, nil\n}\n\n// ListDir lists one directory level (directories first, then files, each\n// alphabetical) for the \"@\" file-reference menu. rel resolves against the active\n// tab workspace. The menu navigates one level at a time, never recursively —\n// bounded for huge trees.\nfunc (a *App) ListDir(rel string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdir := base\n\tif rel != \"\" {\n\t\tpath, ok, err := workspacePathForBase(base, rel)\n\t\tif err != nil || !ok {\n\t\t\treturn []DirEntry{}\n\t\t}\n\t\tdir = path\n\t}\n\tes, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn []DirEntry{}\n\t}\n\tdirs, files := []DirEntry{}, []DirEntry{}\n\tfor _, e := range es {\n\t\tname := e.Name()\n\t\tif skipWorkspaceEntry(rel, name, e.IsDir()) {\n\t\t\tcontinue\n\t\t}\n\t\tif e.IsDir() {\n\t\t\tdirs = append(dirs, DirEntry{Name: name, IsDir: true})\n\t\t\tcontinue\n\t\t}\n\t\tinfo, err := e.Info()\n\t\tif err != nil || !info.Mode().IsRegular() {\n\t\t\tcontinue\n\t\t}\n\t\tfiles = append(files, DirEntry{Name: name, IsDir: false})\n\t}\n\tsort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })\n\tsort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })\n\treturn append(dirs, files...)\n}\n\n// SearchFileRefs finds workspace files by basename for bare \"@token\" completion.\nfunc (a *App) SearchFileRefs(query string) []DirEntry {\n\tbase, err := a.activeWorkspaceBase()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tresults := fileref.Search(base, query, fileRefSearchLimit)\n\tout := make([]DirEntry, 0, len(results))\n\tfor _, r := range results {\n\t\tout = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})\n\t}\n\treturn out\n}\n\n// ReadFile returns a small text preview for a file under the current workspace.\nfunc (a *App) ReadFile(rel string) FilePreview {\n\tout := FilePreview{Path: rel}\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\tout.Err = \"invalid path\"\n\t\treturn out\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tif info.IsDir() {\n\t\tout.Err = \"path is a directory\"\n\t\treturn out\n\t}\n\tif !info.Mode().IsRegular() {\n\t\tout.Err = \"path is not a regular file\"\n\t\treturn out\n\t}\n\tout.Size = info.Size()\n\tif kind, mime := previewMediaKind(path); kind != \"\" {\n\t\ttoken := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())\n\t\tout.Kind = kind\n\t\tout.Mime = mime\n\t\tout.URL = \"/__reasonix_workspace_media/\" + token + \"/\" + url.PathEscape(info.Name())\n\t\treturn out\n\t}\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdefer f.Close()\n\n\tbuf := make([]byte, filePreviewLimit+1)\n\tn, err := f.Read(buf)\n\tif err != nil && err != io.EOF {\n\t\tout.Err = err.Error()\n\t\treturn out\n\t}\n\tdata := buf[:n]\n\tif len(data) > filePreviewLimit {\n\t\tdata = data[:filePreviewLimit]\n\t\tout.Truncated = true\n\t}\n\n\t// Check for BOM first (just the first 2-3 bytes — always complete\n\t// even at a truncation boundary). BOM-prefixed files skip the NUL\n\t// check since UTF-16 normally contains 0x00 for ASCII characters.\n\tbomKind := fileenc.DetectQuick(data)\n\tif bomKind != fileenc.UTF8 {\n\t\tenc, _ := fileenc.Detect(data)\n\t\tif enc == fileenc.LossyUTF8 {\n\t\t\tout.Binary = true\n\t\t\treturn out\n\t\t}\n\t\tdecoded := fileenc.Decode(data, enc)\n\t\tout.Body = string(decoded)\n\t\treturn out\n\t}\n\n\t// No BOM — NUL in raw bytes is a binary signal.\n\tif bytes.Contains(data, []byte{0}) {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\n\t// Trim any partial multi-byte rune at the truncation boundary BEFORE\n\t// encoding detection. Without this, a large UTF-8 file truncated\n\t// mid-character would fail utf8.Valid and be misdetected as GB18030\n\t// or LossyUTF8, producing mojibake or a false binary classification.\n\tif out.Truncated {\n\t\tdata = trimUTF8PartialSuffix(data)\n\t}\n\tenc, _ := fileenc.Detect(data)\n\tif enc == fileenc.LossyUTF8 {\n\t\tout.Binary = true\n\t\treturn out\n\t}\n\tout.Body = string(fileenc.Decode(data, enc))\n\treturn out\n}\n\n// OpenWorkspacePath opens a file or folder from the workspace in the OS default app.\nfunc (a *App) OpenWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn openWorkspacePath(path)\n}\n\n// RevealWorkspacePath shows a workspace file in the native file manager.\nfunc (a *App) RevealWorkspacePath(rel string) error {\n\tpath, ok, err := a.workspacePath(rel)\n\tif err != nil || !ok {\n\t\treturn os.ErrInvalid\n\t}\n\treturn revealPath(path)\n}\n\n// RevealPath shows an arbitrary absolute path in the native file manager.\nfunc (a *App) RevealPath(path string) error {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn os.ErrInvalid\n\t}\n\tif abs, err := filepath.Abs(path); err == nil {\n\t\tpath = abs\n\t}\n\treturn revealPath(path)\n}\n\nfunc revealPath(path string) error {\n\tswitch goruntime.GOOS {\n\tcase \"darwin\":\n\t\treturn exec.Command(\"open\", \"-R\", path).Start()\n\tcase \"windows\":\n\t\t// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the\n\t\t// launch environment can strip it), so resolve it directly rather than\n\t\t// relying on a PATH lookup.\n\t\texplorer := \"explorer.exe\"\n\t\troot := os.Getenv(\"SystemRoot\")\n\t\tif root == \"\" {\n\t\t\troot = os.Getenv(\"windir\")\n\t\t}\n\t\tif root != \"\" {\n\t\t\texplorer = filepath.Join(root, \"explorer.exe\")\n\t\t}\n\t\treturn exec.Command(explorer, \"/select,\", path).Start()\n\tdefault:\n\t\tdir := path\n\t\tif info, err := os.Stat(path); err == nil && !info.IsDir() {\n\t\t\tdir = filepath.Dir(path)\n\t\t}\n\t\treturn exec.Command(\"xdg-open\", dir).Start()\n\t}\n}\n\nfunc (a *App) noticeForTab(tabID, text string) {\n\ttab := a.tabByID(tabID)\n\tif tab != nil && tab.sink != nil {\n\t\ttab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})\n\t}\n}\n\nfunc (a *App) runEffortCommandForTab(tabID, input string) {\n\tentry, err := a.currentProviderEntryForTab(tabID)\n\tif err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tcap := config.EffortCapabilityForEntry(entry)\n\tif !cap.Supported {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort is not configurable for %s\", entry.Name))\n\t\treturn\n\t}\n\targs := strings.Fields(input)\n\tif len(args) < 2 {\n\t\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s: %s (default: %s; options: %s)\", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, \"|\")))\n\t\treturn\n\t}\n\tif len(args) > 2 {\n\t\ta.noticeForTab(tabID, \"usage: /effort \"+strings.Join(cap.Levels, \"|\"))\n\t\treturn\n\t}\n\teffort, err := config.NormalizeEffort(entry, args[1])\n\tif err != nil {\n\t\ta.noticeForTab(tabID, err.Error())\n\t\treturn\n\t}\n\tif err := a.SetEffortForTab(tabID, args[1]); err != nil {\n\t\ta.noticeForTab(tabID, \"effort: \"+err.Error())\n\t\treturn\n\t}\n\tdisplay := effort\n\tif display == \"\" {\n\t\tdisplay = \"auto\"\n\t}\n\ta.noticeForTab(tabID, fmt.Sprintf(\"effort for %s set to %s\", entry.Name, display))\n}\n\nfunc (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {\n\ta.mu.RLock()\n\tref := \"\"\n\tworkspaceRoot := \"\"\n\teffortOverride := (*string)(nil)\n\tif tab := a.tabByIDLocked(tabID); tab != nil {\n\t\tref = tab.model\n\t\tworkspaceRoot = tab.WorkspaceRoot\n\t\teffortOverride = cloneStringPtr(tab.effort)\n\t}\n\ta.mu.RUnlock()\n\tcfg, err := config.LoadForRoot(workspaceRoot)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif strings.TrimSpace(ref) == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, _, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tentry, ok := cfg.ResolveModel(resolved)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown model %q\", resolved)\n\t}\n\tif effortOverride != nil {\n\t\tentry.Effort = *effortOverride\n\t}\n\treturn entry, nil\n}\n\nfunc (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {\n\tif tab == nil {\n\t\treturn \"\", false, fmt.Errorf(\"no active tab\")\n\t}\n\tcfg, err := config.LoadForRoot(tab.WorkspaceRoot)\n\tif err != nil {\n\t\treturn \"\", false, err\n\t}\n\tref := strings.TrimSpace(tab.model)\n\tif ref == \"\" {\n\t\tref = cfg.DefaultModel\n\t}\n\tresolved, fallback, ok := cfg.ResolveModelWithFallback(ref)\n\tif !ok {\n\t\treturn \"\", false, fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\treturn resolved, fallback, nil\n}\n\nfunc (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {\n\tvar result string\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tvar err error\n\t\tresult, err = fn()\n\t\treturn err\n\t})\n\treturn result, err\n}\n\nfunc (a *App) withActiveWorkspaceDo(fn func() error) error {\n\troot := a.activeWorkspaceRoot()\n\tif root != \"\" && root != \".\" {\n\t\tprev, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := os.Chdir(root); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer func() { _ = os.Chdir(prev) }()\n\t}\n\treturn fn()\n}\n\n// SavePastedImage stores a browser clipboard image data URL under the active\n// tab's workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedImage(dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveImageDataURL(dataURL)\n\t})\n}\n\n// SaveClipboardImage reads the native OS clipboard image under the active tab's\n// workspace .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SaveClipboardImage() (string, error) {\n\treturn a.withActiveWorkspace(control.SaveClipboardImage)\n}\n\n// SavePastedFile stores a dropped non-image file (the browser exposes its bytes\n// as a data URL but not a real path) under the active tab's workspace\n// .reasonix/attachments and returns the relative @-reference path.\nfunc (a *App) SavePastedFile(name, dataURL string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.SaveAttachmentDataURL(name, dataURL)\n\t})\n}\n\n// PickExportFile opens the native save dialog and returns the selected path. It\n// returns \"\" when the user cancels.\nfunc (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {\n\tif a.ctx == nil {\n\t\treturn \"\", nil\n\t}\n\tdefaultFilename = safeExportFilename(defaultFilename)\n\text := strings.ToLower(filepath.Ext(defaultFilename))\n\tpath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{\n\t\tTitle: \"Export session\",\n\t\tDefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),\n\t\tDefaultFilename: defaultFilename,\n\t\tCanCreateDirectories: true,\n\t\tFilters: exportFileFilters(mimeType, ext),\n\t})\n\tif err != nil || path == \"\" {\n\t\treturn \"\", err\n\t}\n\tif ext != \"\" && filepath.Ext(path) == \"\" {\n\t\tpath += ext\n\t}\n\treturn path, nil\n}\n\n// SaveExportFile writes an exported session payload to a path previously picked\n// by PickExportFile. An empty path is treated as a cancelled export.\nfunc (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn nil\n\t}\n\tvar data []byte\n\tvar err error\n\tif base64Encoded {\n\t\tdata, err = base64.StdEncoding.DecodeString(payload)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"decode export payload: %w\", err)\n\t\t}\n\t} else {\n\t\tdata = []byte(payload)\n\t}\n\tif err := os.WriteFile(path, data, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc safeExportFilename(name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"reasonix-session.md\"\n\t}\n\treturn filepath.Base(name)\n}\n\nfunc exportFileFilters(mimeType, ext string) []runtime.FileFilter {\n\tswitch mimeType {\n\tcase \"text/markdown\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"Markdown (*.md)\", Pattern: \"*.md\"}}\n\tcase \"application/json\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"JSON (*.json)\", Pattern: \"*.json\"}}\n\tcase \"application/pdf\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PDF (*.pdf)\", Pattern: \"*.pdf\"}}\n\tcase \"image/png\":\n\t\treturn []runtime.FileFilter{{DisplayName: \"PNG image (*.png)\", Pattern: \"*.png\"}}\n\t}\n\tif ext != \"\" {\n\t\treturn []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, \".\")) + \" files (*\" + ext + \")\", Pattern: \"*\" + ext}}\n\t}\n\treturn []runtime.FileFilter{{DisplayName: \"All files (*.*)\", Pattern: \"*.*\"}}\n}\n\n// AttachmentDataURL returns a safe data URL for a stored image attachment.\nfunc (a *App) AttachmentDataURL(path string) (string, error) {\n\treturn a.withActiveWorkspace(func() (string, error) {\n\t\treturn control.ImageDataURL(path)\n\t})\n}\n\n// DroppedItem is one OS-dropped file resolved into a composer context entry: an\n// in-tree file becomes a workspace @reference (read in place, no copy), while an\n// image or out-of-tree file is copied into .reasonix/attachments.\ntype DroppedItem struct {\n\tKind string `json:\"kind\"` // \"workspace\" | \"attachment\"\n\tPath string `json:\"path\"`\n\tIsDir bool `json:\"isDir,omitempty\"`\n\tPreviewURL string `json:\"previewUrl,omitempty\"`\n}\n\n// AttachDropped turns an absolute path from the native file-drop bridge into a\n// composer context entry. Images are stored as attachments so the chip shows a\n// thumbnail; other in-workspace files are referenced relatively (no copy); files\n// outside the workspace are copied into .reasonix/attachments.\nfunc (a *App) AttachDropped(path string) (DroppedItem, error) {\n\tvar item DroppedItem\n\terr := a.withActiveWorkspaceDo(func() error {\n\t\tinfo, err := os.Lstat(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isImageExt(path) {\n\t\t\tif rel, err := control.SaveImageFile(path); err == nil {\n\t\t\t\tpreview, _ := control.ImageDataURL(rel)\n\t\t\t\titem = DroppedItem{Kind: \"attachment\", Path: rel, PreviewURL: preview}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\tif rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {\n\t\t\titem = DroppedItem{Kind: \"workspace\", Path: rel, IsDir: info.IsDir()}\n\t\t\treturn nil\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn fmt.Errorf(\"can only attach files from outside the workspace\")\n\t\t}\n\t\trel, err := control.SaveAttachmentFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\titem = DroppedItem{Kind: \"attachment\", Path: rel}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn DroppedItem{}, err\n\t}\n\treturn item, nil\n}\n\nfunc isImageExt(path string) bool {\n\tswitch strings.ToLower(filepath.Ext(path)) {\n\tcase \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\":\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc workspaceRelativeIn(path, workspaceRoot string) (string, bool) {\n\troot := workspaceRoot\n\tif !filepath.IsAbs(root) {\n\t\tabs, err := filepath.Abs(root)\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\troot = abs\n\t}\n\trel, err := filepath.Rel(root, path)\n\tif err != nil {\n\t\treturn \"\", false\n\t}\n\tif rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) || filepath.IsAbs(rel) {\n\t\treturn \"\", false\n\t}\n\treturn filepath.ToSlash(rel), true\n}\n\n// --- memory panel (frontend ⇄ controller) ---\n\n// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.\ntype MemoryDoc struct {\n\tPath string `json:\"path\"`\n\tScope string `json:\"scope\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryFact is one saved auto-memory, surfaced read-only in the panel.\ntype MemoryFact struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n}\n\n// MemoryArchive is one archived auto-memory kept only for inspection.\ntype MemoryArchive struct {\n\tName string `json:\"name\"`\n\tTitle string `json:\"title,omitempty\"`\n\tDescription string `json:\"description\"`\n\tType string `json:\"type\"`\n\tBody string `json:\"body\"`\n\tPath string `json:\"path\"`\n\tArchivedAt string `json:\"archivedAt,omitempty\"`\n}\n\n// MemoryScope is one writable quick-add target (scope id + the file it writes to).\ntype MemoryScope struct {\n\tScope string `json:\"scope\"`\n\tPath string `json:\"path\"`\n}\n\n// MemoryView is the whole memory panel payload: hierarchical docs, active saved\n// facts, archived facts, and the writable scopes for the quick-add selector.\ntype MemoryView struct {\n\tDocs []MemoryDoc `json:\"docs\"`\n\tFacts []MemoryFact `json:\"facts\"`\n\tArchives []MemoryArchive `json:\"archives\"`\n\tScopes []MemoryScope `json:\"scopes\"`\n\tStoreDir string `json:\"storeDir\"`\n\tStoreGlobalDir string `json:\"storeGlobalDir,omitempty\"`\n\tAvailable bool `json:\"available\"`\n}\n\n// writableScopes are the quick-add targets the panel offers, broad → specific.\nvar writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}\n\n// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,\n// active/archived auto-memories, and the writable scopes. Read-only; mutations\n// go through Remember / SaveDoc.\nfunc (a *App) Memory() MemoryView {\n\treturn a.memoryForCtrl(nil, true)\n}\n\n// MemoryForTab returns the loaded memory for a specific tab's controller,\n// so the panel can show memory for any open project, not just the active tab.\n// If the tab does not exist or has no controller, returns an empty view\n// instead of falling back to the active tab (which would show the wrong data).\n// An empty tabID is treated as \"no tab specified\" and falls back to the\n// active tab for backward compatibility.\nfunc (a *App) MemoryForTab(tabID string) MemoryView {\n\tif tabID == \"\" {\n\t\treturn a.memoryForCtrl(nil, true)\n\t}\n\treturn a.memoryForCtrl(a.ctrlByTabID(tabID), false)\n}\n\nfunc (a *App) memoryForCtrl(ctrl *control.Controller, fallback bool) MemoryView {\n\tview := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn view\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn view\n\t\t}\n\t}\n\tset := ctrl.Memory()\n\tif set == nil {\n\t\treturn view\n\t}\n\tview.StoreDir = set.Store.Dir\n\tview.StoreGlobalDir = set.Store.GlobalDir\n\tview.Available = true\n\tfor _, d := range set.Docs {\n\t\tview.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})\n\t}\n\tfor _, f := range set.Store.List() {\n\t\tview.Facts = append(view.Facts, MemoryFact{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t})\n\t}\n\tfor _, f := range set.Store.ListArchived() {\n\t\tarchivedAt := \"\"\n\t\tif !f.ArchivedAt.IsZero() {\n\t\t\tarchivedAt = f.ArchivedAt.Format(time.RFC3339)\n\t\t}\n\t\tview.Archives = append(view.Archives, MemoryArchive{\n\t\t\tName: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,\n\t\t\tPath: f.Path, ArchivedAt: archivedAt,\n\t\t})\n\t}\n\tfor _, sc := range writableScopes {\n\t\tif p := set.DocPath(sc); p != \"\" {\n\t\t\tview.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})\n\t\t}\n\t}\n\treturn view\n}\n\n// Remember quick-adds a one-line note to the doc-memory file for scope — the\n// panel's explicit \"remember\" action, equivalent to typing \"/remember \".\n// An unknown scope falls back to project. Returns the file written.\nfunc (a *App) Remember(scope, note string) (string, error) {\n\treturn a.rememberForCtrl(nil, scope, note, true)\n}\n\nfunc (a *App) RememberForTab(tabID, scope, note string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.rememberForCtrl(nil, scope, note, true)\n\t}\n\treturn a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)\n}\n\nfunc (a *App) rememberForCtrl(ctrl *control.Controller, scope, note string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.QuickAdd(parseScope(scope), note)\n}\n\n// Forget deletes a saved auto-memory by name — the panel's delete action for a\n// fact the model owns. A no-op when no controller is attached.\nfunc (a *App) Forget(name string) error {\n\treturn a.forgetForCtrl(nil, name, true)\n}\n\nfunc (a *App) ForgetForTab(tabID, name string) error {\n\tif tabID == \"\" {\n\t\treturn a.forgetForCtrl(nil, name, true)\n\t}\n\treturn a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)\n}\n\nfunc (a *App) forgetForCtrl(ctrl *control.Controller, name string, fallback bool) error {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn ctrl.ForgetMemory(name)\n}\n\n// SaveDoc overwrites a memory doc with the panel editor's contents. The controller\n// validates path against the recognized memory files. Returns the file written.\nfunc (a *App) SaveDoc(path, body string) (string, error) {\n\treturn a.saveDocForCtrl(nil, path, body, true)\n}\n\nfunc (a *App) SaveDocForTab(tabID, path, body string) (string, error) {\n\tif tabID == \"\" {\n\t\treturn a.saveDocForCtrl(nil, path, body, true)\n\t}\n\treturn a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)\n}\n\n// tabReadOnly reports whether a tab is open in read-only mode (e.g. a channel transcript).\nfunc (a *App) tabReadOnly(tabID string) bool {\n\ttab := a.tabByID(tabID)\n\treturn tab != nil && tab.ReadOnly\n}\n\nfunc (a *App) saveDocForCtrl(ctrl *control.Controller, path, body string, fallback bool) (string, error) {\n\tif ctrl == nil {\n\t\tif !fallback {\n\t\t\treturn \"\", nil\n\t\t}\n\t\ta.mu.RLock()\n\t\tctrl = a.activeCtrlLocked()\n\t\ta.mu.RUnlock()\n\t\tif ctrl == nil {\n\t\t\treturn \"\", nil\n\t\t}\n\t}\n\treturn ctrl.SaveDoc(path, body)\n}\n\n// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.\nfunc parseScope(s string) memory.Scope {\n\tswitch memory.Scope(s) {\n\tcase memory.ScopeUser:\n\t\treturn memory.ScopeUser\n\tcase memory.ScopeLocal:\n\t\treturn memory.ScopeLocal\n\tdefault:\n\t\treturn memory.ScopeProject\n\t}\n}\n\n// onboardingKeyEnv is the default provider (deepseek) key from config.Default().\nconst onboardingKeyEnv = \"DEEPSEEK_API_KEY\"\n\n// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:\n// billing.FetchWithClient surfaces 401/403 for a bad key.\nconst onboardingBalanceURL = \"https://api.deepseek.com/user/balance\"\n\n// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation\n// dialog that replaces web-style confirm() for destructive or important actions.\ntype NativeConfirmRequest struct {\n\tTitle string `json:\"title\"`\n\tMessage string `json:\"message\"`\n\tDetail string `json:\"detail\"`\n\tConfirmLabel string `json:\"confirmLabel\"`\n\tCancelLabel string `json:\"cancelLabel\"`\n\tDestructive bool `json:\"destructive\"`\n}\n\n// ConfirmAction shows a native confirmation dialog and returns true when the user\n// clicks the confirm button. For destructive actions the dialog type is Warning so\n// the platform can apply its danger styling (red tint on macOS, etc.).\nfunc (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {\n\tif a.ctx == nil {\n\t\treturn false, nil\n\t}\n\tdialogType := runtime.QuestionDialog\n\tif req.Destructive {\n\t\tdialogType = runtime.WarningDialog\n\t}\n\tconfirm := req.ConfirmLabel\n\tif confirm == \"\" {\n\t\tconfirm = \"OK\"\n\t}\n\tcancel := req.CancelLabel\n\tif cancel == \"\" {\n\t\tcancel = \"Cancel\"\n\t}\n\ttitle := req.Title\n\tif title == \"\" {\n\t\ttitle = req.Message\n\t}\n\tbody := req.Message\n\tif req.Detail != \"\" {\n\t\tif body != \"\" {\n\t\t\tbody += \"\\n\\n\" + req.Detail\n\t\t} else {\n\t\t\tbody = req.Detail\n\t\t}\n\t}\n\tdefaultBtn := confirm\n\tif req.Destructive {\n\t\t// On destructive actions, make cancel the default so Enter / Space\n\t\t// does NOT accidentally confirm. ESC always maps to CancelButton.\n\t\tdefaultBtn = cancel\n\t}\n\tresult, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{\n\t\tType: dialogType,\n\t\tTitle: title,\n\t\tMessage: body,\n\t\tButtons: []string{confirm, cancel},\n\t\tDefaultButton: defaultBtn,\n\t\tCancelButton: cancel,\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result == confirm, nil\n}\n\nfunc (a *App) NeedsOnboarding() bool {\n\treturn strings.TrimSpace(os.Getenv(onboardingKeyEnv)) == \"\"\n}\n\n// ConnectKey validates apiKey against the balance endpoint, persists it to the\n// global credential store, and rebuilds the controller so the new key takes effect.\nfunc (a *App) ConnectKey(apiKey string) (string, error) {\n\tapiKey = strings.TrimSpace(apiKey)\n\tif apiKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"key is required\")\n\t}\n\tif tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn \"\", rebuildControllerActiveWorkError(\"provider key\")\n\t}\n\tctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)\n\tdefer cancel()\n\tif _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {\n\t\treturn \"\", fmt.Errorf(\"validate: %w\", err)\n\t}\n\twarning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"save: %w\", err)\n\t}\n\tif err := a.rebuild(); err != nil {\n\t\t// Key is persisted; surface the failure but let the next rebuild load it.\n\t\ta.mu.Lock()\n\t\tif tab := a.activeTabLocked(); tab != nil {\n\t\t\ttab.StartupErr = err.Error()\n\t\t}\n\t\ta.mu.Unlock()\n\t}\n\treturn warning, nil\n}\n"}}, "internal/config/config.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785745649.0360134, "n": 121470, "h": "a1a8e3d014d3e722", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tServe ServeConfig `toml:\"serve\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n\texpansionEnv map[string]string\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\" json:\"key1\"` // default: palette\n\tKey2 string `toml:\"2\" json:\"key2\"` // default: workspace\n\tKey3 string `toml:\"3\" json:\"key3\"` // default: new\n\tKey4 string `toml:\"4\" json:\"key4\"` // default: history\n\tKey5 string `toml:\"5\" json:\"key5\"` // default: dock\n\tKey6 string `toml:\"6\" json:\"key6\"` // default: sidebar\n\tKey7 string `toml:\"7\" json:\"key7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// CompressToolOutputEnabled reports whether token-saving tool output compression\n// is active. Default true (SHA-256 caching + line dedup + JSON minification).\nfunc (c *Config) CompressToolOutputEnabled() bool {\n\tif c == nil || c.Agent.CompressToolOutput == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.CompressToolOutput\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n\tToken string `toml:\"token\"` // optional Bearer token; clients must pass ?token=\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// ServeConfig controls the HTTP serve frontend security settings.\ntype ServeConfig struct {\n\t// AuthMode selects the authentication mode for the HTTP serve frontend.\n\t// \"none\" (default): no authentication.\n\t// \"token\": a pre-shared token in the URL query string.\n\t// \"password\": a login page with bcrypt password verification.\n\tAuthMode string `toml:\"auth_mode\"`\n\t// Token is a pre-shared token for auth_mode = \"token\". When empty, a\n\t// cryptographically random token is generated at startup and printed.\n\tToken string `toml:\"token\"`\n\t// PasswordHash is a bcrypt hash of the password for auth_mode = \"password\".\n\t// Generate one with: reasonix serve --hash-password --password '...'\n\tPasswordHash string `toml:\"password_hash\"`\n\t// BehindProxy indicates the server sits behind a trusted reverse proxy\n\t// (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and\n\t// X-Forwarded-Proto headers. When true, those headers are used for\n\t// rate-limiting and Secure-cookie decisions. When false (default), they\n\t// are ignored — an attacker can otherwise forge them.\n\tBehindProxy bool `toml:\"behind_proxy\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: c.expandVars(c.Network.ProxyURL),\n\t\tNoProxy: c.expandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: c.expandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: c.expandVars(c.Network.Proxy.Username),\n\t\tPassword: c.expandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := c.expandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = c.expandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n\texpansionEnv map[string]string\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\texpansionEnv := loadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.setExpansionEnv(expansionEnv)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tcfg.setExpansionEnv(expansionEnv)\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc (c *Config) setExpansionEnv(env map[string]string) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.expansionEnv = cloneStringMap(env)\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].expansionEnv = c.expansionEnv\n\t}\n}\n\nfunc cloneStringMap(in map[string]string) map[string]string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]string, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\n// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto\n// this entry for an immediate connectivity probe. Normal runtime resolution does\n// not call this; loaded provider entries still resolve only from Reasonix's\n// global .env.\nfunc (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {\n\tif e == nil {\n\t\treturn\n\t}\n\tkey := strings.TrimSpace(e.APIKeyEnv)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tvalue := strings.TrimSpace(os.Getenv(key))\n\tif value == \"\" {\n\t\treturn\n\t}\n\te.resolvedAPIKey = value\n\te.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: \"setup prompt\"}\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "3c1bc90980dfd3a280f12ba588255f66b436626d": {"t": 1785750389.1344945, "n": 121470, "h": "a1a8e3d014d3e722", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tServe ServeConfig `toml:\"serve\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n\texpansionEnv map[string]string\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\" json:\"key1\"` // default: palette\n\tKey2 string `toml:\"2\" json:\"key2\"` // default: workspace\n\tKey3 string `toml:\"3\" json:\"key3\"` // default: new\n\tKey4 string `toml:\"4\" json:\"key4\"` // default: history\n\tKey5 string `toml:\"5\" json:\"key5\"` // default: dock\n\tKey6 string `toml:\"6\" json:\"key6\"` // default: sidebar\n\tKey7 string `toml:\"7\" json:\"key7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// CompressToolOutputEnabled reports whether token-saving tool output compression\n// is active. Default true (SHA-256 caching + line dedup + JSON minification).\nfunc (c *Config) CompressToolOutputEnabled() bool {\n\tif c == nil || c.Agent.CompressToolOutput == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.CompressToolOutput\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n\tToken string `toml:\"token\"` // optional Bearer token; clients must pass ?token=\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// ServeConfig controls the HTTP serve frontend security settings.\ntype ServeConfig struct {\n\t// AuthMode selects the authentication mode for the HTTP serve frontend.\n\t// \"none\" (default): no authentication.\n\t// \"token\": a pre-shared token in the URL query string.\n\t// \"password\": a login page with bcrypt password verification.\n\tAuthMode string `toml:\"auth_mode\"`\n\t// Token is a pre-shared token for auth_mode = \"token\". When empty, a\n\t// cryptographically random token is generated at startup and printed.\n\tToken string `toml:\"token\"`\n\t// PasswordHash is a bcrypt hash of the password for auth_mode = \"password\".\n\t// Generate one with: reasonix serve --hash-password --password '...'\n\tPasswordHash string `toml:\"password_hash\"`\n\t// BehindProxy indicates the server sits behind a trusted reverse proxy\n\t// (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and\n\t// X-Forwarded-Proto headers. When true, those headers are used for\n\t// rate-limiting and Secure-cookie decisions. When false (default), they\n\t// are ignored — an attacker can otherwise forge them.\n\tBehindProxy bool `toml:\"behind_proxy\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: c.expandVars(c.Network.ProxyURL),\n\t\tNoProxy: c.expandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: c.expandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: c.expandVars(c.Network.Proxy.Username),\n\t\tPassword: c.expandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := c.expandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = c.expandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n\texpansionEnv map[string]string\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\texpansionEnv := loadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.setExpansionEnv(expansionEnv)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tcfg.setExpansionEnv(expansionEnv)\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc (c *Config) setExpansionEnv(env map[string]string) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.expansionEnv = cloneStringMap(env)\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].expansionEnv = c.expansionEnv\n\t}\n}\n\nfunc cloneStringMap(in map[string]string) map[string]string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]string, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\n// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto\n// this entry for an immediate connectivity probe. Normal runtime resolution does\n// not call this; loaded provider entries still resolve only from Reasonix's\n// global .env.\nfunc (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {\n\tif e == nil {\n\t\treturn\n\t}\n\tkey := strings.TrimSpace(e.APIKeyEnv)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tvalue := strings.TrimSpace(os.Getenv(key))\n\tif value == \"\" {\n\t\treturn\n\t}\n\te.resolvedAPIKey = value\n\te.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: \"setup prompt\"}\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "ab47ae1e57370ca7016348c33eaa83c57b5a628a": {"t": 1785750390.6678712, "n": 121371, "h": "4963c78c9c7f62ad", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tServe ServeConfig `toml:\"serve\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n\texpansionEnv map[string]string\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\" json:\"key1\"` // default: palette\n\tKey2 string `toml:\"2\" json:\"key2\"` // default: workspace\n\tKey3 string `toml:\"3\" json:\"key3\"` // default: new\n\tKey4 string `toml:\"4\" json:\"key4\"` // default: history\n\tKey5 string `toml:\"5\" json:\"key5\"` // default: dock\n\tKey6 string `toml:\"6\" json:\"key6\"` // default: sidebar\n\tKey7 string `toml:\"7\" json:\"key7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// CompressToolOutputEnabled reports whether token-saving tool output compression\n// is active. Default true (SHA-256 caching + line dedup + JSON minification).\nfunc (c *Config) CompressToolOutputEnabled() bool {\n\tif c == nil || c.Agent.CompressToolOutput == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.CompressToolOutput\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// ServeConfig controls the HTTP serve frontend security settings.\ntype ServeConfig struct {\n\t// AuthMode selects the authentication mode for the HTTP serve frontend.\n\t// \"none\" (default): no authentication.\n\t// \"token\": a pre-shared token in the URL query string.\n\t// \"password\": a login page with bcrypt password verification.\n\tAuthMode string `toml:\"auth_mode\"`\n\t// Token is a pre-shared token for auth_mode = \"token\". When empty, a\n\t// cryptographically random token is generated at startup and printed.\n\tToken string `toml:\"token\"`\n\t// PasswordHash is a bcrypt hash of the password for auth_mode = \"password\".\n\t// Generate one with: reasonix serve --hash-password --password '...'\n\tPasswordHash string `toml:\"password_hash\"`\n\t// BehindProxy indicates the server sits behind a trusted reverse proxy\n\t// (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and\n\t// X-Forwarded-Proto headers. When true, those headers are used for\n\t// rate-limiting and Secure-cookie decisions. When false (default), they\n\t// are ignored — an attacker can otherwise forge them.\n\tBehindProxy bool `toml:\"behind_proxy\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: c.expandVars(c.Network.ProxyURL),\n\t\tNoProxy: c.expandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: c.expandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: c.expandVars(c.Network.Proxy.Username),\n\t\tPassword: c.expandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := c.expandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = c.expandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n\texpansionEnv map[string]string\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\texpansionEnv := loadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.setExpansionEnv(expansionEnv)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tcfg.setExpansionEnv(expansionEnv)\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc (c *Config) setExpansionEnv(env map[string]string) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.expansionEnv = cloneStringMap(env)\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].expansionEnv = c.expansionEnv\n\t}\n}\n\nfunc cloneStringMap(in map[string]string) map[string]string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]string, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\n// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto\n// this entry for an immediate connectivity probe. Normal runtime resolution does\n// not call this; loaded provider entries still resolve only from Reasonix's\n// global .env.\nfunc (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {\n\tif e == nil {\n\t\treturn\n\t}\n\tkey := strings.TrimSpace(e.APIKeyEnv)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tvalue := strings.TrimSpace(os.Getenv(key))\n\tif value == \"\" {\n\t\treturn\n\t}\n\te.resolvedAPIKey = value\n\te.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: \"setup prompt\"}\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "4fe39d3092146290b1c60b2a77f869909f77f74e": {"t": 1785750392.1523697, "n": 121436, "h": "5010f52f2c8f6696", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tServe ServeConfig `toml:\"serve\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n\texpansionEnv map[string]string\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\" json:\"key1\"` // default: palette\n\tKey2 string `toml:\"2\" json:\"key2\"` // default: workspace\n\tKey3 string `toml:\"3\" json:\"key3\"` // default: new\n\tKey4 string `toml:\"4\" json:\"key4\"` // default: history\n\tKey5 string `toml:\"5\" json:\"key5\"` // default: dock\n\tKey6 string `toml:\"6\" json:\"key6\"` // default: sidebar\n\tKey7 string `toml:\"7\" json:\"key7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// CompressToolOutputEnabled reports whether token-saving tool output compression\n// is active. Default true (SHA-256 caching + line dedup + JSON minification).\nfunc (c *Config) CompressToolOutputEnabled() bool {\n\tif c == nil || c.Agent.CompressToolOutput == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.CompressToolOutput\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// ServeConfig controls the HTTP serve frontend security settings.\ntype ServeConfig struct {\n\t// AuthMode selects the authentication mode for the HTTP serve frontend.\n\t// \"none\" (default): no authentication.\n\t// \"token\": a pre-shared token in the URL query string.\n\t// \"password\": a login page with bcrypt password verification.\n\tAuthMode string `toml:\"auth_mode\"`\n\t// Token is a pre-shared token for auth_mode = \"token\". When empty, a\n\t// cryptographically random token is generated at startup and printed.\n\tToken string `toml:\"token\"`\n\t// PasswordHash is a bcrypt hash of the password for auth_mode = \"password\".\n\t// Generate one with: reasonix serve --hash-password --password '...'\n\tPasswordHash string `toml:\"password_hash\"`\n\t// BehindProxy indicates the server sits behind a trusted reverse proxy\n\t// (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and\n\t// X-Forwarded-Proto headers. When true, those headers are used for\n\t// rate-limiting and Secure-cookie decisions. When false (default), they\n\t// are ignored — an attacker can otherwise forge them.\n\tBehindProxy bool `toml:\"behind_proxy\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: c.expandVars(c.Network.ProxyURL),\n\t\tNoProxy: c.expandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: c.expandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: c.expandVars(c.Network.Proxy.Username),\n\t\tPassword: c.expandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := c.expandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = c.expandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n\texpansionEnv map[string]string\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\texpansionEnv := loadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.setExpansionEnv(expansionEnv)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tcfg.setExpansionEnv(expansionEnv)\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc (c *Config) setExpansionEnv(env map[string]string) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.expansionEnv = cloneStringMap(env)\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].expansionEnv = c.expansionEnv\n\t}\n}\n\nfunc cloneStringMap(in map[string]string) map[string]string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]string, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\n// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto\n// this entry for an immediate connectivity probe. Normal runtime resolution does\n// not call this; loaded provider entries still resolve only from Reasonix's\n// global .env.\nfunc (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {\n\tif e == nil {\n\t\treturn\n\t}\n\tkey := strings.TrimSpace(e.APIKeyEnv)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tvalue := strings.TrimSpace(os.Getenv(key))\n\tif value == \"\" {\n\t\treturn\n\t}\n\te.resolvedAPIKey = value\n\te.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: \"setup prompt\"}\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "e8447a7a77c75afadb2b2c448d913701ea58d728": {"t": 1785750393.646025, "n": 105895, "h": "171f3c8a64317ffd", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tServe ServeConfig `toml:\"serve\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n\texpansionEnv map[string]string\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// ServeConfig controls the HTTP serve frontend security settings.\ntype ServeConfig struct {\n\t// AuthMode selects the authentication mode for the HTTP serve frontend.\n\t// \"none\" (default): no authentication.\n\t// \"token\": a pre-shared token in the URL query string.\n\t// \"password\": a login page with bcrypt password verification.\n\tAuthMode string `toml:\"auth_mode\"`\n\t// Token is a pre-shared token for auth_mode = \"token\". When empty, a\n\t// cryptographically random token is generated at startup and printed.\n\tToken string `toml:\"token\"`\n\t// PasswordHash is a bcrypt hash of the password for auth_mode = \"password\".\n\t// Generate one with: reasonix serve --hash-password --password '...'\n\tPasswordHash string `toml:\"password_hash\"`\n\t// BehindProxy indicates the server sits behind a trusted reverse proxy\n\t// (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and\n\t// X-Forwarded-Proto headers. When true, those headers are used for\n\t// rate-limiting and Secure-cookie decisions. When false (default), they\n\t// are ignored — an attacker can otherwise forge them.\n\tBehindProxy bool `toml:\"behind_proxy\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: c.expandVars(c.Network.ProxyURL),\n\t\tNoProxy: c.expandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: c.expandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: c.expandVars(c.Network.Proxy.Username),\n\t\tPassword: c.expandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := c.expandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = c.expandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n\texpansionEnv map[string]string\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\texpansionEnv := loadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.setExpansionEnv(expansionEnv)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tcfg.setExpansionEnv(expansionEnv)\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc (c *Config) setExpansionEnv(env map[string]string) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.expansionEnv = cloneStringMap(env)\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].expansionEnv = c.expansionEnv\n\t}\n}\n\nfunc cloneStringMap(in map[string]string) map[string]string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]string, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\n// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto\n// this entry for an immediate connectivity probe. Normal runtime resolution does\n// not call this; loaded provider entries still resolve only from Reasonix's\n// global .env.\nfunc (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {\n\tif e == nil {\n\t\treturn\n\t}\n\tkey := strings.TrimSpace(e.APIKeyEnv)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tvalue := strings.TrimSpace(os.Getenv(key))\n\tif value == \"\" {\n\t\treturn\n\t}\n\te.resolvedAPIKey = value\n\te.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: \"setup prompt\"}\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "535993a271e2d97864cbf2406ffb0f71fb317d80": {"t": 1785750395.123056, "n": 105889, "h": "97096bec81b27c98", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tServe ServeConfig `toml:\"serve\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n\texpansionEnv map[string]string\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// ServeConfig controls the HTTP serve frontend security settings.\ntype ServeConfig struct {\n\t// AuthMode selects the authentication mode for the HTTP serve frontend.\n\t// \"none\" (default): no authentication.\n\t// \"token\": a pre-shared token in the URL query string.\n\t// \"password\": a login page with bcrypt password verification.\n\tAuthMode string `toml:\"auth_mode\"`\n\t// Token is a pre-shared token for auth_mode = \"token\". When empty, a\n\t// cryptographically random token is generated at startup and printed.\n\tToken string `toml:\"token\"`\n\t// PasswordHash is a bcrypt hash of the password for auth_mode = \"password\".\n\t// Generate one with: reasonix serve --hash-password \n\tPasswordHash string `toml:\"password_hash\"`\n\t// BehindProxy indicates the server sits behind a trusted reverse proxy\n\t// (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and\n\t// X-Forwarded-Proto headers. When true, those headers are used for\n\t// rate-limiting and Secure-cookie decisions. When false (default), they\n\t// are ignored — an attacker can otherwise forge them.\n\tBehindProxy bool `toml:\"behind_proxy\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: c.expandVars(c.Network.ProxyURL),\n\t\tNoProxy: c.expandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: c.expandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: c.expandVars(c.Network.Proxy.Username),\n\t\tPassword: c.expandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := c.expandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = c.expandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n\texpansionEnv map[string]string\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\texpansionEnv := loadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.setExpansionEnv(expansionEnv)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tcfg.setExpansionEnv(expansionEnv)\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc (c *Config) setExpansionEnv(env map[string]string) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.expansionEnv = cloneStringMap(env)\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].expansionEnv = c.expansionEnv\n\t}\n}\n\nfunc cloneStringMap(in map[string]string) map[string]string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]string, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\n// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto\n// this entry for an immediate connectivity probe. Normal runtime resolution does\n// not call this; loaded provider entries still resolve only from Reasonix's\n// global .env.\nfunc (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {\n\tif e == nil {\n\t\treturn\n\t}\n\tkey := strings.TrimSpace(e.APIKeyEnv)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tvalue := strings.TrimSpace(os.Getenv(key))\n\tif value == \"\" {\n\t\treturn\n\t}\n\te.resolvedAPIKey = value\n\te.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: \"setup prompt\"}\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "1d564bd4d5062f059a4432c31b5fd75a66550df7": {"t": 1785750396.584425, "n": 104730, "h": "bc9b2601213e6782", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n\texpansionEnv map[string]string\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: c.expandVars(c.Network.ProxyURL),\n\t\tNoProxy: c.expandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: c.expandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: c.expandVars(c.Network.Proxy.Username),\n\t\tPassword: c.expandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = c.expandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := c.expandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = c.expandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n\texpansionEnv map[string]string\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\texpansionEnv := loadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.setExpansionEnv(expansionEnv)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tcfg.setExpansionEnv(expansionEnv)\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc (c *Config) setExpansionEnv(env map[string]string) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.expansionEnv = cloneStringMap(env)\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].expansionEnv = c.expansionEnv\n\t}\n}\n\nfunc cloneStringMap(in map[string]string) map[string]string {\n\tif len(in) == 0 {\n\t\treturn nil\n\t}\n\tout := make(map[string]string, len(in))\n\tfor k, v := range in {\n\t\tout[k] = v\n\t}\n\treturn out\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\n// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto\n// this entry for an immediate connectivity probe. Normal runtime resolution does\n// not call this; loaded provider entries still resolve only from Reasonix's\n// global .env.\nfunc (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {\n\tif e == nil {\n\t\treturn\n\t}\n\tkey := strings.TrimSpace(e.APIKeyEnv)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tvalue := strings.TrimSpace(os.Getenv(key))\n\tif value == \"\" {\n\t\treturn\n\t}\n\te.resolvedAPIKey = value\n\te.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: \"setup prompt\"}\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "2dd6fc6ddf40c3a04544e2a706404755c6dc0757": {"t": 1785750398.0595512, "n": 104147, "h": "f7d9ad2737a30e38", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\n// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto\n// this entry for an immediate connectivity probe. Normal runtime resolution does\n// not call this; loaded provider entries still resolve only from Reasonix's\n// global .env.\nfunc (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {\n\tif e == nil {\n\t\treturn\n\t}\n\tkey := strings.TrimSpace(e.APIKeyEnv)\n\tif key == \"\" {\n\t\treturn\n\t}\n\tvalue := strings.TrimSpace(os.Getenv(key))\n\tif value == \"\" {\n\t\treturn\n\t}\n\te.resolvedAPIKey = value\n\te.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: \"setup prompt\"}\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "7e7c7eda097114c87b2247e036c0df8311bd7c62": {"t": 1785750399.5283413, "n": 103536, "h": "2956e6d95ad048b8", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tresolvedAPIKey string\n\tresolvedSource CredentialSource\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. Provider api_key_env values\n// resolve from Reasonix's global .env, not from project .env files.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .mcp.json are resolved independently without\n// changing the process cwd, while provider keys stay rooted in Reasonix home.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\tresolveProviderCredentialsForRoot(root, cfg)\n\treturn cfg, nil\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. Reasonix's global .env is loaded so api_key_env\n// resolution works while the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global .env file under Reasonix\n// home. It is the single source for provider credentials saved by Reasonix, so\n// stale shell, Windows, project, or home env vars cannot silently override keys\n// the user saved through setup or settings. \"\" when Reasonix home can't be\n// resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \".env\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e == nil {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn e.resolvedAPIKey\n\t}\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\tvalue, _, ok := storedCredentialValue(e.APIKeyEnv)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\treturn value\n}\n\nfunc (e *ProviderEntry) APIKeySourceLabel() string {\n\tif e == nil || strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn \"\"\n\t}\n\tif e.resolvedAPIKey != \"\" {\n\t\treturn credentialSourceLabel(e.resolvedSource)\n\t}\n\treturn ResolveCredentialForRootGlobalFirst(\".\", e.APIKeyEnv).Source.Label\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "a2f335336abaa6d7499e2a35ba28d424d4b2ad6b": {"t": 1785750401.4505162, "n": 118649, "h": "04dc61729162eeaf", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\" json:\"key1\"` // default: palette\n\tKey2 string `toml:\"2\" json:\"key2\"` // default: workspace\n\tKey3 string `toml:\"3\" json:\"key3\"` // default: new\n\tKey4 string `toml:\"4\" json:\"key4\"` // default: history\n\tKey5 string `toml:\"5\" json:\"key5\"` // default: dock\n\tKey6 string `toml:\"6\" json:\"key6\"` // default: sidebar\n\tKey7 string `toml:\"7\" json:\"key7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// CompressToolOutputEnabled reports whether token-saving tool output compression\n// is active. Default true (SHA-256 caching + line dedup + JSON minification).\nfunc (c *Config) CompressToolOutputEnabled() bool {\n\tif c == nil || c.Agent.CompressToolOutput == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.CompressToolOutput\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "658bc7ce463249024f8a2860a55a9713c610517e": {"t": 1785750403.2968297, "n": 103173, "h": "8d9080314aa8312e", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// UserDecisionPolicy is appended to every system prompt, including user-custom\n// prompts, so custom personas cannot accidentally remove the `ask` UI contract.\nconst UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "1cca45d217c6fe2bda609e96db797d115f0b09cd": {"t": 1785750404.833084, "n": 118347, "h": "c1cfe51eb18b2289", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\" json:\"key1\"` // default: palette\n\tKey2 string `toml:\"2\" json:\"key2\"` // default: workspace\n\tKey3 string `toml:\"3\" json:\"key3\"` // default: new\n\tKey4 string `toml:\"4\" json:\"key4\"` // default: history\n\tKey5 string `toml:\"5\" json:\"key5\"` // default: dock\n\tKey6 string `toml:\"6\" json:\"key6\"` // default: sidebar\n\tKey7 string `toml:\"7\" json:\"key7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// CompressToolOutputEnabled reports whether token-saving tool output compression\n// is active. Default true (SHA-256 caching + line dedup + JSON minification).\nfunc (c *Config) CompressToolOutputEnabled() bool {\n\tif c == nil || c.Agent.CompressToolOutput == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.CompressToolOutput\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "66a016261b533bac73c1afc9eaeef1792b9ebe36": {"t": 1785750406.359021, "n": 117942, "h": "a7a79249768df9f0", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "9ada14176629b1d59d7ed78446951b2bb5954904": {"t": 1785750408.1947796, "n": 102903, "h": "58a03bee45f70db8", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "01d9b1730edf7be9f130a3296f8ecdc3ae3bc742": {"t": 1785750411.0030906, "n": 102293, "h": "df7ba28ba34cb82b", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.Agent.AutoPlan = userAutoPlanMode()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\nfunc userAutoPlanMode() string {\n\tcfg := Default()\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\t_ = mergeFile(cfg, uc)\n\t}\n\tswitch strings.ToLower(strings.TrimSpace(cfg.Agent.AutoPlan)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "62180a99d685f6460b9afa96d1277323e7f0b4be": {"t": 1785750412.9047365, "n": 102611, "h": "60f2fc737f6fa421", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// User-global runtime controls, such as agent step limits, are documented exceptions.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 0,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t\tif err := mergeRuntimeTOMLFile(cfg, uc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tglobalMaxSteps := cfg.Agent.MaxSteps\n\tglobalPlannerMaxSteps := cfg.Agent.PlannerMaxSteps\n\n\ttomlSources = append(tomlSources, projectTOML)\n\tif err := mergeRuntimeTOMLFile(cfg, projectTOML); err != nil {\n\t\treturn nil, err\n\t}\n\t// Runtime step caps are user/global controls, not project policy. Keep the\n\t// project config's other fields, but do not let ./reasonix.toml override\n\t// the user's execution and planner round limits.\n\tcfg.Agent.MaxSteps = globalMaxSteps\n\tcfg.Agent.PlannerMaxSteps = globalPlannerMaxSteps\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\nfunc mergeRuntimeTOMLFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t}\n\t}\n\treturn mergeFile(cfg, path)\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "fe3bf9ccfa629dbf7623d4a8879e29fd68496e81": {"t": 1785750414.4248097, "n": 117040, "h": "9ab8b62a4387490a", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "7ba4295565251b6911e7761bd0e825d29aa75cec": {"t": 1785750416.321979, "n": 102001, "h": "c0b5c51497816009", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier is a legacy compatibility field. New config rendering omits it; enabled\n\t// MCP servers connect automatically in the background unless auto_start=false.\n\t// Historical values are accepted for old files:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — legacy alias for background.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"background\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"background\") with the\n// project default applied. Legacy lazy and unknown values fall back to\n// background so enabled MCPs are available without manual connection.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\", \"lazy\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "2ff0c3de3f6ca80b583929a8f6d822cff042896f": {"t": 1785750417.795879, "n": 117018, "h": "e37b71723daa222b", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "d9e453a1bedd675c226660cab033da35421b141d": {"t": 1785750419.7267907, "n": 101979, "h": "3e1de4d5f3df8465", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration.\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tmigratedMimo := normalizeConfigForEdit(cfg)\n\tif migratedMimo && strings.TrimSpace(path) != \"\" {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := cfg.SaveTo(path); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) bool {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tmigratedMimo := normalizeLegacyMimoCustomProviders(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\treturn migratedMimo\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeLegacyMimoProviderCatalogs(c *Config) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tchanged := false\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) == \"\" || len(p.Models) > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tswitch officialProviderHost(p.BaseURL) {\n\t\tcase \"api.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoAPIModels(), []string{\"mimo-v2.5\", \"mimo-v2-omni\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\tcase \"token-plan-cn.xiaomimimo.com\":\n\t\t\tif applyLegacyMimoCatalog(p, legacyMimoTokenPlanModels(), []string{\"mimo-v2.5\"}, \"mimo-v2.5-pro\") {\n\t\t\t\tchanged = true\n\t\t\t}\n\t\t}\n\t}\n\treturn changed\n}\n\nfunc applyLegacyMimoCatalog(p *ProviderEntry, models, visionModels []string, fallbackDefault string) bool {\n\tif p == nil || len(models) == 0 {\n\t\treturn false\n\t}\n\tbeforeModels := append([]string(nil), p.Models...)\n\tbeforeVision := append([]string(nil), p.VisionModels...)\n\tbeforeDefault := p.Default\n\tbeforeModel := p.Model\n\tbeforeWindow := p.ContextWindow\n\tbeforeNoProxy := p.NoProxy\n\tbeforePricesLen := len(p.Prices)\n\n\tcurrentDefault := strings.TrimSpace(p.Default)\n\tif currentDefault == \"\" {\n\t\tcurrentDefault = strings.TrimSpace(p.Model)\n\t}\n\tp.Models = mergeModelLists(models, p.ModelList())\n\tp.Model = p.Models[0]\n\tp.Default = firstKnownModel(currentDefault, p.Models, fallbackDefault)\n\tp.VisionModels = mergeModelLists(visionModels, p.VisionModels)\n\tbackfillOfficialContextWindow(p, 1_048_576)\n\tp.NoProxy = true\n\tif p.Prices == nil {\n\t\tp.Prices = mimoDomesticPrices(models)\n\t} else {\n\t\tfor model, price := range mimoDomesticPrices(models) {\n\t\t\tif p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = price\n\t\t\t}\n\t\t}\n\t}\n\n\treturn !stringSlicesEqual(beforeModels, p.Models) ||\n\t\t!stringSlicesEqual(beforeVision, p.VisionModels) ||\n\t\tbeforeDefault != p.Default ||\n\t\tbeforeModel != p.Model ||\n\t\tbeforeWindow != p.ContextWindow ||\n\t\tbeforeNoProxy != p.NoProxy ||\n\t\tbeforePricesLen != len(p.Prices)\n}\n\nfunc stringSlicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i := range a {\n\t\tif a[i] != b[i] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeLegacyMimoCustomProviders(c *Config) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, legacyMimoConfigRefs(c)...)\n}\n\n// NormalizeLegacyMimoCustomProvidersForRefs appends custom OpenAI-compatible\n// MiMo providers needed by legacy refs that live outside reasonix.toml, such as\n// restored desktop tab state.\nfunc NormalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\treturn normalizeLegacyMimoCustomProvidersForRefs(c, refs...)\n}\n\nfunc normalizeLegacyMimoCustomProvidersForRefs(c *Config, refs ...string) bool {\n\tif c == nil {\n\t\treturn false\n\t}\n\tneeded := map[string]bool{}\n\taddRef := func(ref string) {\n\t\tif name := legacyMimoProviderNameForRef(ref); name != \"\" {\n\t\t\tneeded[name] = true\n\t\t}\n\t}\n\tfor _, ref := range refs {\n\t\taddRef(ref)\n\t}\n\tchanged := normalizeLegacyMimoProviderCatalogs(c)\n\tfor name := range needed {\n\t\tif _, ok := c.Provider(name); ok {\n\t\t\tcontinue\n\t\t}\n\t\tc.Providers = append(c.Providers, legacyMimoCustomProvider(name))\n\t\tchanged = true\n\t}\n\tif normalizeLegacyMimoProviderCatalogs(c) {\n\t\tchanged = true\n\t}\n\treturn changed\n}\n\nfunc legacyMimoConfigRefs(c *Config) []string {\n\tif c == nil {\n\t\treturn nil\n\t}\n\trefs := []string{\n\t\tc.DefaultModel,\n\t\tc.Agent.PlannerModel,\n\t\tc.Agent.SubagentModel,\n\t\tc.Agent.AutoPlanClassifier,\n\t\tc.Bot.Model,\n\t}\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\trefs = append(refs, ref)\n\t}\n\tfor _, conn := range c.Bot.Connections {\n\t\trefs = append(refs, conn.Model)\n\t}\n\trefs = append(refs, c.Desktop.ProviderAccess...)\n\treturn refs\n}\n\nfunc legacyMimoProviderName(ref string) string {\n\tswitch strings.TrimSpace(ref) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\", \"mimo-token-plan\", \"mimo-pro\", \"mimo-flash\":\n\t\treturn strings.TrimSpace(ref)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoProviderNameForRef(ref string) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tproviderName, _, hasModel := strings.Cut(ref, \"/\")\n\tif name := legacyMimoProviderName(providerName); name != \"\" {\n\t\treturn name\n\t}\n\tif hasModel {\n\t\treturn \"\"\n\t}\n\tswitch ref {\n\tcase \"mimo-v2.5-pro\":\n\t\treturn \"mimo-pro\"\n\tcase \"mimo-v2.5\":\n\t\treturn \"mimo-flash\"\n\tcase \"mimo-v2-omni\":\n\t\treturn \"mimo-api\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc legacyMimoAPIModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n}\n\nfunc legacyMimoTokenPlanModels() []string {\n\treturn []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n}\n\nfunc legacyMimoCustomProvider(name string) ProviderEntry {\n\tswitch strings.TrimSpace(name) {\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\", \"mimo-api\":\n\t\tmodels := legacyMimoAPIModels()\n\t\treturn ProviderEntry{\n\t\t\tName: strings.TrimSpace(name),\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tmodels := legacyMimoTokenPlanModels()\n\t\treturn ProviderEntry{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: models,\n\t\t\tVisionModels: []string{\"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tPrices: mimoDomesticPrices(models),\n\t\t\tNoProxy: true,\n\t\t}\n\tcase \"mimo-flash\":\n\t\treturn ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}\n\tdefault:\n\t\treturn ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tnormalizeLegacyMimoProviderCatalogs(c)\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\taddRef(c.Bot.Model)\n\tfor _, conn := range c.Bot.Connections {\n\t\taddRef(conn.Model)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif legacyMimoProviderName(p.Name) != \"\" && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "a2acf8296a540e01dcbc2e74c5b66594c852ae0f": {"t": 1785750421.2478197, "n": 116191, "h": "8e45bf69734d024d", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\t// Only include vision models that are actually in the model list.\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists([]string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "87d9d12d88d97a139ae23f9c21db83c7f9f73803": {"t": 1785750422.7254899, "n": 101106, "h": "7eea457ed899b933", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists(models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc mergeVisionModelsIntoProvider(e *ProviderEntry, models []string) {\n\tif e == nil {\n\t\treturn\n\t}\n\tenabled := map[string]bool{}\n\tfor _, model := range e.ChatModelList() {\n\t\tenabled[model] = true\n\t}\n\tmerged := e.VisionModels\n\tif merged == nil {\n\t\tmerged = models\n\t}\n\tout := make([]string, 0, len(merged))\n\tfor _, model := range merged {\n\t\tif enabled[model] && IsLikelyChatModel(model) {\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\te.VisionModels = out\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "b56b4c016275034b49bad1de773cd5c8f694d0ef": {"t": 1785750424.215886, "n": 115938, "h": "045315e996c151b1", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\t// Only include vision models that are actually in the model list.\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists([]string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML.\nfunc (c *Config) WriteFile(path string) error {\n\treturn os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "0fc430346931cdd0deb7c1cf62b1d6044b3dd05b": {"t": 1785750425.671, "n": 101091, "h": "883dc2bee478e388", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/fileutil\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists(models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc mergeVisionModelsIntoProvider(e *ProviderEntry, models []string) {\n\tif e == nil {\n\t\treturn\n\t}\n\tenabled := map[string]bool{}\n\tfor _, model := range e.ChatModelList() {\n\t\tenabled[model] = true\n\t}\n\tmerged := e.VisionModels\n\tif merged == nil {\n\t\tmerged = models\n\t}\n\tout := make([]string, 0, len(merged))\n\tfor _, model := range merged {\n\t\tif enabled[model] && IsLikelyChatModel(model) {\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\te.VisionModels = out\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML. The write is\n// atomic + fsynced so an interrupted write or power loss can never truncate the\n// main config into an unparseable state that leaves the app with no usable\n// models (#4615, #4708).\nfunc (c *Config) WriteFile(path string) error {\n\treturn fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "2f76c97713d5226aa4c4d7c29d98dbfc1d4b8c50": {"t": 1785750427.13758, "n": 115891, "h": "dd84783f4654455f", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tAgentLog AgentLogConfig `toml:\"agentlog\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// AgentLogConfig is the [agentlog] section for operational log rotation.\ntype AgentLogConfig struct {\n\tEnabled bool `toml:\"enabled\"` // write agent.log at all (default true)\n\tMaxSizeMB int `toml:\"max_size_mb\"` // rotate when file exceeds this (default 10)\n\tMaxBackups int `toml:\"max_backups\"` // keep this many numbered backups (default 5)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\t// Only include vision models that are actually in the model list.\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists([]string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML.\nfunc (c *Config) WriteFile(path string) error {\n\treturn os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "b02a1f65abee1a3607ce9a3525457ad2ccd4737c": {"t": 1785750428.585, "n": 100853, "h": "398e97cea8f9a4f8", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench|creation; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists(models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc mergeVisionModelsIntoProvider(e *ProviderEntry, models []string) {\n\tif e == nil {\n\t\treturn\n\t}\n\tenabled := map[string]bool{}\n\tfor _, model := range e.ChatModelList() {\n\t\tenabled[model] = true\n\t}\n\tmerged := e.VisionModels\n\tif merged == nil {\n\t\tmerged = models\n\t}\n\tout := make([]string, 0, len(merged))\n\tfor _, model := range merged {\n\t\tif enabled[model] && IsLikelyChatModel(model) {\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\te.VisionModels = out\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML.\nfunc (c *Config) WriteFile(path string) error {\n\treturn os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "09f29d33cf96b1ac33dcc3df4a122e82caded4aa": {"t": 1785750430.0308452, "n": 100844, "h": "041c97b4163bc754", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tcase \"creation\":\n\t\treturn \"creation\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists(models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc mergeVisionModelsIntoProvider(e *ProviderEntry, models []string) {\n\tif e == nil {\n\t\treturn\n\t}\n\tenabled := map[string]bool{}\n\tfor _, model := range e.ChatModelList() {\n\t\tenabled[model] = true\n\t}\n\tmerged := e.VisionModels\n\tif merged == nil {\n\t\tmerged = models\n\t}\n\tout := make([]string, 0, len(merged))\n\tfor _, model := range merged {\n\t\tif enabled[model] && IsLikelyChatModel(model) {\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\te.VisionModels = out\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML.\nfunc (c *Config) WriteFile(path string) error {\n\treturn os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}, "823a4b8d546d7ffc509979bbe9a8affac0d7eef1": {"t": 1785750431.5369608, "n": 115473, "h": "11d84ebf1d0827c7", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tBilling BillingConfig `toml:\"billing\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// BillingConfig is the [billing] section.\ntype BillingConfig struct {\n\tAutoExchangeRate bool `toml:\"auto_exchange_rate\"` // fetch live CNY→USD rate on startup\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\t// Only include vision models that are actually in the model list.\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists([]string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML.\nfunc (c *Config) WriteFile(path string) error {\n\treturn os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "e2e4fc3f0091e50ce8e7cb4b7e419f8d7df164bb": {"t": 1785750432.9941254, "n": 115247, "h": "21c159d8c495156d", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n\tMaxObservations int `toml:\"max_observations\"` // ring buffer cap (default 200)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\t// Only include vision models that are actually in the model list.\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists([]string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML.\nfunc (c *Config) WriteFile(path string) error {\n\treturn os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "3b86bf355ad833a51086a7e0210c746c4323b595": {"t": 1785750434.4639428, "n": 115155, "h": "170bc963afd378c3", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n Notifications NotificationsConfig `toml:\"notifications\"`\n Agent AgentConfig `toml:\"agent\"`\n Providers []ProviderEntry `toml:\"providers\"`\n ActiveProfile string `toml:\"active_profile\"` // named profile from [profiles]; \"\" = none\n Profiles map[string]ProfileConfig `toml:\"profiles\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tCodegraph CodegraphConfig `toml:\"codegraph\"`\n\tBuiltInMCP BuiltInMCPConfig `toml:\"builtin_mcp\"`\n\tBuiltInMCPUpdates BuiltInMCPUpdatesConfig `toml:\"builtin_mcp_updates\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\tSchedule ScheduleConfig `toml:\"schedule\"`\n\tLearn LearnConfig `toml:\"learn\"`\n\tMesh MeshConfig `toml:\"mesh\"`\n\tCollab CollabConfig `toml:\"collab\"`\n\tMarketplace MarketplaceConfig `toml:\"marketplace\"`\n\tEmbedding EmbeddingConfig `toml:\"embedding\"`\n\tCredentialsStore string `toml:\"credentials_store\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n\tHotbar HotbarConfig `toml:\"hotbar\"` // keyboard digit keys 1-7 → action mapping\n}\n\n// HotbarConfig maps keyboard digit keys (1-7) to desktop actions.\n// Valid action names: palette, workspace, new, history, dock, sidebar, settings, and \"\" (unbind).\n// Keys not present or set to \"\" keep their built-in default.\ntype HotbarConfig struct {\n\tKey1 string `toml:\"1\"` // default: palette\n\tKey2 string `toml:\"2\"` // default: workspace\n\tKey3 string `toml:\"3\"` // default: new\n\tKey4 string `toml:\"4\"` // default: history\n\tKey5 string `toml:\"5\"` // default: dock\n\tKey6 string `toml:\"6\"` // default: sidebar\n\tKey7 string `toml:\"7\"` // default: settings\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n\tSound bool `toml:\"sound\"` // bell/beep on turn completion\n}\n\n// ProfileConfig is a named bundle of settings that overrides the active session\n// when selected via /profile or the desktop hotbar. Keys map to entries in\n// [profiles.] blocks in reasonix.toml.\ntype ProfileConfig struct {\n\tDescription string `toml:\"description\"`\n\tModel string `toml:\"model\"`\n\tEffort string `toml:\"effort\"`\n\tToolApproveMode string `toml:\"tool_approve_mode\"`\n\tAutoPlan string `toml:\"auto_plan\"`\n\tOutputStyle string `toml:\"output_style\"`\n\tSystemPromptPrefix string `toml:\"system_prompt_prefix\"`\n\tYolo bool `toml:\"yolo\"` // auto-approve tools\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"hermes\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// CodegraphConfig governs the built-in CodeGraph MCP server — symbol/call-graph\n// code intelligence (tree-sitter + SQLite) that gives the agent codegraph_*\n// search / context / explore / trace / node tools. Enabled defaults to true so\n// upgrades keep it for existing configs; first-run scaffolds write enabled =\n// false so only brand-new users start without it. AutoInstall (default true)\n// lets reasonix fetch the CodeGraph runtime into its cache when CodeGraph is\n// enabled but missing; set false to require an explicit `reasonix codegraph\n// install` (e.g. for air-gapped or headless runs). Path overrides binary\n// resolution; empty resolves the cache, then a `codegraph` on PATH, then a\n// bundle beside the executable. CodeGraph always starts in the background when\n// enabled; legacy tier values are ignored and removed during config load.\ntype CodegraphConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAutoInstall bool `toml:\"auto_install\"`\n\tPath string `toml:\"path\"`\n\tTier string `toml:\"tier\"`\n}\n\nfunc (c CodegraphConfig) ShouldAutoStart() bool {\n\treturn c.Enabled\n}\n\nfunc (c CodegraphConfig) ResolvedTier() string {\n\treturn \"background\"\n}\n\n// BuiltInMCPConfig controls Reasonix-shipped MCP servers that require no user\n// server definition. They are off by default and become provider-visible only\n// after the user enables them.\ntype BuiltInMCPConfig struct {\n\tTimeEnabled bool `toml:\"time_enabled\"`\n\tContext7Enabled bool `toml:\"context7_enabled\"`\n}\n\nfunc (c BuiltInMCPConfig) Enabled(name string) bool {\n\tswitch name {\n\tcase \"time\":\n\t\treturn c.TimeEnabled\n\tcase \"context7\":\n\t\treturn c.Context7Enabled\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c *BuiltInMCPConfig) SetEnabled(name string, enabled bool) bool {\n\tswitch name {\n\tcase \"time\":\n\t\tc.TimeEnabled = enabled\n\t\treturn true\n\tcase \"context7\":\n\t\tc.Context7Enabled = enabled\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc (c BuiltInMCPConfig) EnabledNames() []string {\n\tvar out []string\n\tif c.TimeEnabled {\n\t\tout = append(out, \"time\")\n\t}\n\tif c.Context7Enabled {\n\t\tout = append(out, \"context7\")\n\t}\n\treturn out\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tDiscord DiscordBotConfig `toml:\"discord\"`\n\tTelegram TelegramBotConfig `toml:\"telegram\"`\n\tLine LineBotConfig `toml:\"line\"`\n\tSlack SlackBotConfig `toml:\"slack\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// ScheduleConfig is the [schedule] block for cron-driven automated agent tasks.\n// When tasks are present and enabled, the scheduler starts on agent launch.\ntype ScheduleConfig struct {\n\tTasks []ScheduleTaskConfig `toml:\"tasks\"`\n}\n\n// ScheduleTaskConfig is one entry in [[schedule.tasks]].\ntype ScheduleTaskConfig struct {\n\tName string `toml:\"name\"`\n\tCron string `toml:\"cron\"`\n\tPrompt string `toml:\"prompt\"`\n\tModel string `toml:\"model\"` // optional; empty = use default_model\n\tEnabled *bool `toml:\"enabled\"` // nil or true = enabled\n}\n\n// LearnConfig controls self-improving skill loops. The learner observes agent\n// behaviour across turns, detects repeated tool/skill patterns, and enables a\n// post-session reflection turn to generate new SKILL.md files.\ntype LearnConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tMaxPatterns int `toml:\"max_patterns\"` // max patterns to detect (default 20)\n\tMinConfidence int `toml:\"min_confidence\"` // observations before pattern forms (default 3)\n}\n\n// MeshConfig is the [mesh] section for agent-to-agent MCP delegation.\ntype MeshConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tPeers []MeshPeerConfig `toml:\"peers\"`\n}\n\n// MeshPeerConfig is one entry in [[mesh.peers]].\ntype MeshPeerConfig struct {\n\tName string `toml:\"name\"`\n\tURL string `toml:\"url\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tEnabled bool `toml:\"enabled\"`\n}\n\n// CollabConfig is the [collab] section for live collaborative session sharing.\ntype CollabConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tListenAddr string `toml:\"listen_addr\"` // WebSocket listen address, e.g. \":9091\"\n}\n\n// MarketplaceConfig is the [marketplace] section for community skill registries.\ntype MarketplaceConfig struct {\n\tLobeHub LobeHubMarketplaceConfig `toml:\"lobehub\"`\n}\n\n// LobeHubMarketplaceConfig is the [marketplace.lobehub] section.\ntype LobeHubMarketplaceConfig struct {\n\tEnabled bool `toml:\"enabled\"` // when true, auto-register and sync on startup\n\tClientID string `toml:\"client_id\"` // persistent client ID (auto-registers if empty)\n\tClientSecret string `toml:\"client_secret\"` // persistent client secret\n\tClientName string `toml:\"client_name\"` // registration name, default \"reasonix-hermes\"\n\tClientType string `toml:\"client_type\"` // registration type, default \"cli\"\n\tSyncOnStartup bool `toml:\"sync_on_startup\"` // run full sync on agent startup\n\tQuery string `toml:\"query\"` // optional search filter for synced skills\n\tSort string `toml:\"sort\"` // sort field, default \"installCount\"\n\tCategory string `toml:\"category\"` // optional category filter\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tDiscordUsers []string `toml:\"discord_users\"`\n\tTelegramUsers []string `toml:\"telegram_users\"`\n\tLineUsers []string `toml:\"line_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n\tDiscordGroups []string `toml:\"discord_groups\"`\n\tTelegramGroups []string `toml:\"telegram_groups\"`\n\tLineGroups []string `toml:\"line_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// DiscordBotConfig configures a Discord bot.\ntype DiscordBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. DISCORD_BOT_TOKEN\n\tServerID string `toml:\"server_id\"` // target guild ID (optional)\n\tChannelID string `toml:\"channel_id\"` // restrict to single channel (optional; empty = all channels)\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n\tWebhookURLEnv string `toml:\"webhook_url_env\"` // env var for notification webhook URL (optional; e.g. DISCORD_WEBHOOK_URL)\n}\n\n// TelegramBotConfig configures a Telegram bot.\ntype TelegramBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. TELEGRAM_BOT_TOKEN\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// LineBotConfig configures a LINE bot.\ntype LineBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var name, e.g. LINE_CHANNEL_TOKEN\n\tSecretEnv string `toml:\"secret_env\"` // env var name, e.g. LINE_CHANNEL_SECRET\n\tAllowDMs bool `toml:\"allow_dms\"` // respond to DMs (default true)\n}\n\n// SlackBotConfig configures a Slack bot via Socket Mode.\ntype SlackBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTokenEnv string `toml:\"token_env\"` // env var for bot token, e.g. SLACK_BOT_TOKEN\n\tAppTokenEnv string `toml:\"app_token_env\"` // env var for app-level token, e.g. SLACK_APP_TOKEN\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n\tAppTokenEnv string `toml:\"app_token_env\"` // Slack app-level token (also used for other socket-mode platforms)\n\tSecretEnv string `toml:\"secret_env\"` // LINE channel secret (also used for other webhook-signing platforms)\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n\t// RemoteSandboxURL is the API endpoint for a remote sandbox backend (OpenSandbox).\n\t// Only used when Bash == \"remote\".\n\tRemoteSandboxURL string `toml:\"remote_sandbox_url\"`\n\t// RemoteSandboxToken is the bearer auth token for the remote sandbox API.\n\tRemoteSandboxToken string `toml:\"remote_sandbox_token\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// CompressToolOutput enables token-saving compression on tool results via\n\t// SHA-256 content caching, line dedup, and JSON minification (default true).\n\tCompressToolOutput *bool `toml:\"compress_tool_output\"`\n\t// Auxiliary overrides which model handles background jobs: compaction\n\t// summarization, vision/image processing, and web-page extraction. Each entry\n\t// names a [[providers]] instance. When unset the main model handles it.\n\tAuxiliary AuxiliaryConfig `toml:\"auxiliary\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// AuxiliaryConfig selects alternate models for background jobs that don't need\n// the full reasoning power of the main model. Pointing compaction, vision, and\n// web extraction at a fast cheap model (e.g. MiMo-V2-Flash or DeepSeek V4 Flash\n// via Ollama Cloud) keeps the main model's context budget for real reasoning.\ntype AuxiliaryConfig struct {\n\tCompression AuxModelRef `toml:\"compression\"` // compaction summarizer\n\tVision AuxModelRef `toml:\"vision\"` // image/vision requests\n\tWebExtract AuxModelRef `toml:\"web_extract\"` // web-page content extraction\n}\n\n// AuxModelRef names a configured [[providers]] instance to use for an auxiliary\n// job. Both fields must be set for the override to take effect.\ntype AuxModelRef struct {\n\tProvider string `toml:\"provider\"` // provider name, e.g. \"ollamacloud-flash\"\n\tModel string `toml:\"model\"` // model id, e.g. \"deepseek-v4-flash\"\n}\n\n// IsSet reports whether both Provider and Model are non-empty.\nfunc (r AuxModelRef) IsSet() bool {\n\treturn strings.TrimSpace(r.Provider) != \"\" && strings.TrimSpace(r.Model) != \"\"\n}\n\n// EmbeddingConfig controls text embedding for the memory server's dense vector\n// search. When set, facts stored via hindsight_retain are automatically embedded\n// using the configured provider/model, and hindsight_recall can use dense=true\n// for cosine-similarity semantic search alongside the existing TF-IDF sparse index.\ntype EmbeddingConfig struct {\n\tProvider string `toml:\"provider\"` // provider name (e.g. \"deepseek\" or an openai-compatible entry)\n\tModel string `toml:\"model\"` // embedding model id (e.g. \"text-embedding-3-small\")\n\tAPIKeyEnv string `toml:\"api_key_env\"` // env var for the API key (falls back to the provider's key when empty)\n\tBatchSize int `toml:\"batch_size\"` // max facts per embedding API call (default 20); set 0 to embed one-by-one\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// VisionModels is a curated list of model IDs that support image input. It\n\t// supplements auto-detection via name heuristics and can be used when the\n\t// provider offers multiple vision-capable models that don't follow naming\n\t// conventions. Models listed here are automatically marked as vision-capable\n\t// and surfaced in the model selector as image-input candidates.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\n**After an approved plan, sign off each step with complete_step — include the\nevidence (verification command + result, or diff, or file changes) that proves\nthe step is done. Never batch completions: sign off one sub-step at a time.**\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// CodeGraph code-intelligence defaults on so existing configs (which never\n\t\t// wrote a [codegraph] section) keep it after an upgrade. First-run scaffolds\n\t\t// write enabled = false instead, so only brand-new users start without it.\n\t\t// AutoInstall fetches the runtime into the cache when enabled and missing.\n\t\tCodegraph: CodegraphConfig{Enabled: true, AutoInstall: true},\n\t\t// Time is dependency-free and bundled, so expose it by default. Context7\n\t\t// can invoke a package runner and remains opt-in.\n\t\tBuiltInMCP: BuiltInMCPConfig{TimeEnabled: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\t// Only include vision models that are actually in the model list.\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t\tif p.VisionModels == nil {\n\t\t\t\tvar filtered []string\n\t\t\t\tfor _, vm := range visionModels {\n\t\t\t\t\tif p.HasModel(vm) {\n\t\t\t\t\t\tfiltered = append(filtered, vm)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tp.VisionModels = filtered\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"},\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists([]string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects. Paths under the home directory are\n// relativized first so the slug stays short and readable.\nfunc WorkspaceSlug(absPath string) string {\n\tclean := filepath.Clean(absPath)\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tif rel, err := filepath.Rel(home, clean); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\tclean = \"$HOME/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\", \" \", \"-\").Replace(clean)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML.\nfunc (c *Config) WriteFile(path string) error {\n\treturn os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n\nconst (\n\tBuiltInMCPUpdateModeOff = \"off\"\n\tBuiltInMCPUpdateModeNotify = \"notify\"\n\tBuiltInMCPUpdateModeDownload = \"download\"\n\tBuiltInMCPUpdateModeAutoNextSession = \"auto_next_session\"\n\tdefaultBuiltInMCPUpdateInterval = 24 * time.Hour\n)\n\ntype BuiltInMCPUpdatesConfig struct {\n\tMode string `toml:\"mode\"`\n\tCheckInterval string `toml:\"check_interval\"`\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Mode)) {\n\tcase BuiltInMCPUpdateModeOff:\n\t\treturn BuiltInMCPUpdateModeOff\n\tcase BuiltInMCPUpdateModeDownload:\n\t\treturn BuiltInMCPUpdateModeDownload\n\tcase BuiltInMCPUpdateModeAutoNextSession:\n\t\treturn BuiltInMCPUpdateModeAutoNextSession\n\tdefault:\n\t\treturn BuiltInMCPUpdateModeNotify\n\t}\n}\n\nfunc (c BuiltInMCPUpdatesConfig) CheckIntervalDuration() time.Duration {\n\traw := strings.TrimSpace(c.CheckInterval)\n\tif raw == \"\" {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\td, err := time.ParseDuration(raw)\n\tif err != nil || d <= 0 {\n\t\treturn defaultBuiltInMCPUpdateInterval\n\t}\n\treturn d\n}\n\nfunc (c BuiltInMCPUpdatesConfig) ResolvedCheckInterval() string {\n\treturn c.CheckIntervalDuration().String()\n}\n"}, "fc5fe53e2dc970162bc57ae1e343ba8c91acffc5": {"t": 1785750435.9393723, "n": 100806, "h": "9535f77ac8550eef", "c": "// Package config loads Reasonix's runtime configuration from TOML. Resolution order:\n// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.\n// Secrets come from the environment via api_key_env and are never stored in\n// config files.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/netip\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/BurntSushi/toml\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nvar validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)\n\n// IsValidSkillName reports whether name is a usable skill identifier.\nfunc IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }\n\n// SkillNameKey normalizes a skill identifier for config comparisons.\nfunc SkillNameKey(name string) string {\n\tname = strings.TrimSpace(name)\n\tif !IsValidSkillName(name) {\n\t\treturn \"\"\n\t}\n\tif runtime.GOOS == \"windows\" {\n\t\treturn strings.ToLower(name)\n\t}\n\treturn name\n}\n\n// Config is Reasonix's runtime configuration.\ntype Config struct {\n\tConfigVersion int `toml:\"config_version\"`\n\tDefaultModel string `toml:\"default_model\"`\n\tLanguage string `toml:\"language\"` // ui/model language tag (e.g. \"zh\"); empty = auto-detect from $LANG / $REASONIX_LANG\n\tCredentialsStore string `toml:\"credentials_store\"`\n\tUI UIConfig `toml:\"ui\"`\n\tDesktop DesktopConfig `toml:\"desktop\"`\n\tNotifications NotificationsConfig `toml:\"notifications\"`\n\tAgent AgentConfig `toml:\"agent\"`\n\tProviders []ProviderEntry `toml:\"providers\"`\n\tTools ToolsConfig `toml:\"tools\"`\n\tPermissions PermissionsConfig `toml:\"permissions\"`\n\tSandbox SandboxConfig `toml:\"sandbox\"`\n\tNetwork NetworkConfig `toml:\"network\"`\n\tPlugins []PluginEntry `toml:\"plugins\"`\n\tSkills SkillsConfig `toml:\"skills\"`\n\tStatusline StatuslineConfig `toml:\"statusline\"`\n\tLSP LSPConfig `toml:\"lsp\"`\n\tBot BotConfig `toml:\"bot\"`\n\n\tproviderSources map[string]providerSourceScope\n\tshadowedProjectProviders []ProviderEntry\n}\n\ntype providerSourceScope string\n\nconst (\n\tproviderSourceUser providerSourceScope = \"user\"\n\tproviderSourceProject providerSourceScope = \"project\"\n)\n\n// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in\n// DesktopConfig so desktop preferences cannot alter terminal output or prompts.\ntype UIConfig struct {\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tShortcutLayout string `toml:\"shortcut_layout\"` // classic|desktop; accepted for compatibility\n\tCloseBehavior string `toml:\"close_behavior\"` // legacy desktop close behavior; prefer desktop.close_behavior\n\tShowReasoning bool `toml:\"show_reasoning\"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed\n}\n\n// DesktopConfig controls desktop-only UI preferences. It is intentionally\n// separate from top-level language and [ui] so desktop choices do not affect CLI\n// language, terminal colours, or provider-visible prompt/request data.\ntype DesktopConfig struct {\n\tLanguage string `toml:\"language\"` // auto|en|zh; empty/auto = browser/OS auto-detect\n\tLayoutStyle string `toml:\"layout_style\"` // classic|workbench; desktop layout style\n\tTheme string `toml:\"theme\"` // auto|dark|light; empty resolves to auto\n\tThemeStyle string `toml:\"theme_style\"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\tCloseBehavior string `toml:\"close_behavior\"` // quit|background; desktop window close behavior\n\tDisplayMode string `toml:\"display_mode\"` // standard|compact (legacy \"minimal\" maps to compact); transcript display mode\n\tStatusBarStyle string `toml:\"status_bar_style\"` // icon|text; desktop status bar metric labels\n\tStatusBarItems []string `toml:\"status_bar_items\"` // ordered visible desktop status bar items\n\tCheckUpdates *bool `toml:\"check_updates\"` // startup update checks; nil keeps the default enabled\n\tTelemetry *bool `toml:\"telemetry\"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled\n\tMetrics *bool `toml:\"metrics\"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled\n\tProviderAccess []string `toml:\"provider_access\"` // desktop-only list of provider entries shown in Settings > Model > Access\n\tExpandThinking bool `toml:\"expand_thinking\"` // true = show reasoning text expanded by default; false = collapsed\n}\n\n// NotificationsConfig controls optional system notifications for CLI chat/run.\ntype NotificationsConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tTurnDone bool `toml:\"turn_done\"`\n\tApprovalRequest bool `toml:\"approval_request\"`\n\tAskRequest bool `toml:\"ask_request\"`\n}\n\n// UITheme normalizes ui.theme to a supported value.\nfunc (c *Config) UITheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// UIThemeStyle normalizes ui.theme_style. Empty means \"pick the default style\n// for the resolved light/dark shell\".\nfunc (c *Config) UIThemeStyle() string {\n\treturn normalizeThemeStyle(c.UI.ThemeStyle)\n}\n\n// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept\n// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both\n// layouts.\nfunc (c *Config) UIShortcutLayout() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {\n\tcase \"desktop\", \"dual\", \"dual-axis\", \"dual_axis\":\n\t\treturn \"desktop\"\n\tdefault:\n\t\treturn \"classic\"\n\t}\n}\n\nfunc normalizeThemeStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"graphite\", \"aurora\", \"slate\", \"carbon\", \"nocturne\", \"amber\", \"ember\", \"midnight\", \"sandstone\", \"porcelain\", \"linen\", \"glacier\":\n\t\treturn strings.ToLower(strings.TrimSpace(style))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopLayoutStyle(style string) string {\n\tswitch strings.ToLower(strings.TrimSpace(style)) {\n\tcase \"classic\":\n\t\treturn \"classic\"\n\tcase \"workbench\", \"workspace\":\n\t\treturn \"workbench\"\n\tdefault:\n\t\treturn \"workbench\"\n\t}\n}\n\nfunc normalizeCloseBehavior(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"quit\", \"exit\":\n\t\treturn \"quit\"\n\tdefault:\n\t\treturn \"background\"\n\t}\n}\n\n// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect\n// from the browser/OS locale; it deliberately does not read top-level language,\n// which is used by the CLI/model-facing runtime.\nfunc (c *Config) DesktopLanguage() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {\n\tcase \"en\":\n\t\treturn \"en\"\n\tcase \"zh\":\n\t\treturn \"zh\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// DesktopTheme normalizes desktop.theme. New desktop users default to the OS\n// automatic graphite product look; an explicit auto/light/dark is preserved.\nfunc (c *Config) DesktopTheme() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {\n\tcase \"auto\":\n\t\treturn \"auto\"\n\tcase \"light\":\n\t\treturn \"light\"\n\tcase \"dark\":\n\t\treturn \"dark\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend\n// chooses the default style for the resolved desktop theme.\nfunc (c *Config) DesktopThemeStyle() string {\n\treturn normalizeThemeStyle(c.Desktop.ThemeStyle)\n}\n\n// DesktopLayoutStyle normalizes the desktop layout style. New installs default\n// to workbench; explicit classic remains respected.\nfunc (c *Config) DesktopLayoutStyle() string {\n\tif strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), \"workbench\") && strings.TrimSpace(c.Desktop.LayoutStyle) == \"\" {\n\t\treturn \"workbench\"\n\t}\n\treturn normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)\n}\n\n// DesktopCloseBehavior normalizes the desktop close-window preference. It falls\n// back to the legacy ui.close_behavior value for configs written before [desktop]\n// existed.\nfunc (c *Config) DesktopCloseBehavior() string {\n\tif strings.TrimSpace(c.Desktop.CloseBehavior) != \"\" {\n\t\treturn normalizeCloseBehavior(c.Desktop.CloseBehavior)\n\t}\n\treturn normalizeCloseBehavior(c.UI.CloseBehavior)\n}\n\n// UICloseBehavior is the legacy name for DesktopCloseBehavior.\nfunc (c *Config) UICloseBehavior() string {\n\treturn c.DesktopCloseBehavior()\n}\n\n// DesktopDisplayMode normalizes the transcript display mode. Default is\n// \"standard\" (flat rendering, no folding).\nfunc (c *Config) DesktopDisplayMode() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {\n\tcase \"standard\":\n\t\treturn \"standard\"\n\tcase \"compact\", \"minimal\":\n\t\treturn \"compact\"\n\tdefault:\n\t\treturn \"standard\"\n\t}\n}\n\n// DesktopStatusBarStyle normalizes the desktop status bar metric label style.\n// Default is \"text\"; explicit \"icon\" preserves the user's compact choice.\nfunc (c *Config) DesktopStatusBarStyle() string {\n\tswitch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {\n\tcase \"icon\":\n\t\treturn \"icon\"\n\tcase \"text\":\n\t\treturn \"text\"\n\tdefault:\n\t\treturn \"text\"\n\t}\n}\n\nvar defaultDesktopStatusBarItems = []string{\n\t\"model\",\n\t\"workspace\",\n\t\"git_branch\",\n\t\"cache\",\n\t\"cache_avg\",\n\t\"session_tokens\",\n\t\"turn_tokens\",\n\t\"turn_cost\",\n\t\"session_turns\",\n\t\"context\",\n\t\"compact\",\n\t\"cost\",\n\t\"balance\",\n}\n\nvar knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)\n\nfunc desktopStatusBarItemSet(items []string) map[string]bool {\n\tout := make(map[string]bool, len(items))\n\tfor _, item := range items {\n\t\tout[item] = true\n\t}\n\treturn out\n}\n\n// DefaultDesktopStatusBarItems returns the default ordered visible desktop\n// status bar items.\nfunc DefaultDesktopStatusBarItems() []string {\n\treturn append([]string(nil), defaultDesktopStatusBarItems...)\n}\n\n// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.\n// An unset or empty list uses the default full set; explicit non-empty lists\n// preserve user order and omit hidden items.\nfunc (c *Config) DesktopStatusBarItems() []string {\n\treturn normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)\n}\n\nfunc normalizeDesktopStatusBarItems(items []string) []string {\n\tout := make([]string, 0, len(items))\n\tseen := map[string]bool{}\n\tfor _, raw := range items {\n\t\tid := strings.TrimSpace(raw)\n\t\tif !knownDesktopStatusBarItems[id] || seen[id] {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, id)\n\t\tseen[id] = true\n\t}\n\tif len(out) == 0 {\n\t\treturn DefaultDesktopStatusBarItems()\n\t}\n\treturn out\n}\n\n// DesktopCheckUpdates reports whether the desktop should check for updates on\n// startup. Missing configs default to true so existing users keep update notices.\nfunc (c *Config) DesktopCheckUpdates() bool {\n\tif c == nil || c.Desktop.CheckUpdates == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.CheckUpdates\n}\n\n// ColdResumePruneEnabled reports whether stale tool results are elided when a\n// session resumes past the provider cache window. Default true (cheaper cold\n// restart); users keep full history by disabling it.\nfunc (c *Config) ColdResumePruneEnabled() bool {\n\tif c == nil || c.Agent.ColdResumePrune == nil {\n\t\treturn true\n\t}\n\treturn *c.Agent.ColdResumePrune\n}\n\n// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:\n// visible reasoning follows the conversation language already described by the\n// stable LanguagePolicy. Legacy \"default\" is treated as auto.\nfunc (c *Config) ReasoningLanguage() string {\n\tif c == nil {\n\t\treturn \"auto\"\n\t}\n\treturn NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)\n}\n\n// NormalizeReasoningLanguage returns one of auto|zh|en.\nfunc NormalizeReasoningLanguage(lang string) string {\n\tswitch strings.ToLower(strings.TrimSpace(lang)) {\n\tcase \"\", \"auto\", \"follow\", \"conversation\", \"detect\", \"default\", \"model\", \"model-default\", \"model_default\", \"provider\":\n\t\treturn \"auto\"\n\tcase \"zh\", \"cn\", \"chinese\", \"中文\":\n\t\treturn \"zh\"\n\tcase \"en\", \"english\":\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"auto\"\n\t}\n}\n\n// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.\n// It carries no conversation, key, or file data — see desktop/README.md.\nfunc (c *Config) DesktopTelemetry() bool {\n\tif c == nil || c.Desktop.Telemetry == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Telemetry\n}\n\n// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —\n// anonymous (signal, bucket) counters, never content. Default on.\nfunc (c *Config) DesktopMetrics() bool {\n\tif c == nil || c.Desktop.Metrics == nil {\n\t\treturn true\n\t}\n\treturn *c.Desktop.Metrics\n}\n\n// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,\n// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the\n// servers themselves are never bundled — each resolves on PATH and the tool\n// returns an install hint when it is missing, so the capability is dormant until\n// the user installs a server. Servers overrides or extends the built-in language\n// → server map, keyed by language id (e.g. \"go\", \"rust\", \"python\").\ntype LSPConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tServers map[string]LSPServer `toml:\"servers\"`\n}\n\n// LSPServer overrides a built-in language's server or, when keyed by a new\n// language, adds one. An empty field falls back to the built-in default for that\n// language; Extensions is required when adding a language the built-ins don't\n// cover (e.g. \".ex\" for Elixir) so files route to it.\ntype LSPServer struct {\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tLanguageID string `toml:\"language_id\"`\n\tExtensions []string `toml:\"extensions\"`\n\tInstallHint string `toml:\"install_hint\"`\n}\n\n// StatuslineConfig configures a custom status line. Command, when set, is run at\n// startup and after each turn; its first line of stdout replaces the built-in\n// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.\ntype StatuslineConfig struct {\n\tCommand string `toml:\"command\"`\n}\n\n// BotConfig 控制多渠道 IM bot 消息网关。\ntype BotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tModel string `toml:\"model\"` // 用于 bot 的模型名,空则用 default_model\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tMaxSteps int `toml:\"max_steps\"`\n\tDebounceMs int `toml:\"debounce_ms\"` // 消息合并窗口,毫秒\n\tAllowlist BotAllowlist `toml:\"allowlist\"`\n\tQQ QQBotConfig `toml:\"qq\"`\n\tFeishu FeishuBotConfig `toml:\"feishu\"`\n\tWeixin WeixinBotConfig `toml:\"weixin\"`\n\tConnections []BotConnectionConfig `toml:\"connections\"`\n}\n\n// BotAllowlist 控制哪些用户可以使用 bot。\ntype BotAllowlist struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAllowAll bool `toml:\"allow_all\"`\n\tQQUsers []string `toml:\"qq_users\"`\n\tFeishuUsers []string `toml:\"feishu_users\"`\n\tWeixinUsers []string `toml:\"weixin_users\"`\n\tQQGroups []string `toml:\"qq_groups\"`\n\tFeishuGroups []string `toml:\"feishu_groups\"`\n\tWeixinGroups []string `toml:\"weixin_groups\"`\n}\n\n// QQBotConfig QQ 官方 Bot API v2 配置。\ntype QQBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 环境变量名,如 QQ_BOT_APP_SECRET\n\tSandbox bool `toml:\"sandbox\"` // true 使用 QQ 沙箱 API / gateway\n}\n\n// FeishuBotConfig 飞书自建应用 Bot 配置。\ntype FeishuBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tDomain string `toml:\"domain\"` // feishu(默认)| lark\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"` // 如 FEISHU_BOT_APP_SECRET\n\tVerificationToken string `toml:\"verification_token\"` // 事件订阅验证 token\n\tMode string `toml:\"mode\"` // webhook(默认)| websocket\n\tWebhookPort int `toml:\"webhook_port\"` // webhook 模式端口\n\tRequireMention bool `toml:\"require_mention\"`\n}\n\n// WeixinBotConfig 微信 iLink Bot 配置。\ntype WeixinBotConfig struct {\n\tEnabled bool `toml:\"enabled\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"` // 环境变量名,如 WEIXIN_BOT_TOKEN\n\tAPIBase string `toml:\"api_base\"` // iLink API base URL\n}\n\n// BotConnectionConfig is the desktop-friendly connection record for IM bot\n// channels. It keeps install/runtime state separate from legacy per-provider\n// knobs so the UI can expose a simple \"connect first\" flow while old configs\n// keep working.\ntype BotConnectionConfig struct {\n\tID string `toml:\"id\"`\n\tProvider string `toml:\"provider\"` // qq|feishu|weixin\n\tDomain string `toml:\"domain\"` // feishu|lark|weixin|qq\n\tLabel string `toml:\"label\"`\n\tEnabled bool `toml:\"enabled\"`\n\tStatus string `toml:\"status\"` // disconnected|pending|connected|error\n\tModel string `toml:\"model\"`\n\tToolApprovalMode string `toml:\"tool_approval_mode\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tCredential BotConnectionCredential `toml:\"credential\"`\n\tSessionMappings []BotConnectionSessionMapping `toml:\"session_mappings\"`\n\tLastError string `toml:\"last_error\"`\n\tCreatedAt string `toml:\"created_at\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\ntype BotConnectionCredential struct {\n\tAppID string `toml:\"app_id\"`\n\tAppSecretEnv string `toml:\"app_secret_env\"`\n\tAccountID string `toml:\"account_id\"`\n\tTokenEnv string `toml:\"token_env\"`\n}\n\ntype BotConnectionSessionMapping struct {\n\tRemoteID string `toml:\"remote_id\"`\n\tSessionID string `toml:\"session_id\"`\n\tSessionSource string `toml:\"session_source\"`\n\tChatType string `toml:\"chat_type\"`\n\tUserID string `toml:\"user_id\"`\n\tThreadID string `toml:\"thread_id\"`\n\tScope string `toml:\"scope\"`\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tUpdatedAt string `toml:\"updated_at\"`\n}\n\n// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,\n// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.\n// web_fetch reuses these proxy settings while keeping its own SSRF-guarded\n// dialer.\ntype NetworkConfig struct {\n\t// ProxyMode is \"auto\" (default; environment proxy for now), \"env\", \"custom\",\n\t// or \"off\". auto leaves room for OS proxy detection later without changing the\n\t// config shape.\n\tProxyMode string `toml:\"proxy_mode\"`\n\t// ProxyURL is an advanced custom override such as \"socks5://127.0.0.1:7890\".\n\t// When set and proxy_mode = \"custom\", it wins over the structured proxy table.\n\tProxyURL string `toml:\"proxy_url\"`\n\t// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the\n\t// process environment instead.\n\tNoProxy string `toml:\"no_proxy\"`\n\tProxy NetworkProxyConfig `toml:\"proxy\"`\n}\n\n// NetworkProxyConfig is the structured custom-proxy editor shape. Password is\n// optional and supports ${VAR} expansion, so users can avoid storing it literally.\ntype NetworkProxyConfig struct {\n\tType string `toml:\"type\"` // http|https|socks5|socks5h\n\tServer string `toml:\"server\"`\n\tPort int `toml:\"port\"`\n\tUsername string `toml:\"username\"`\n\tPassword string `toml:\"password\"`\n}\n\n// NetworkProxySpec returns the expanded proxy settings used by netclient.\nfunc (c *Config) NetworkProxySpec() netclient.ProxySpec {\n\treturn netclient.ProxySpec{\n\t\tMode: c.Network.ProxyMode,\n\t\tURL: ExpandVars(c.Network.ProxyURL),\n\t\tNoProxy: ExpandVars(c.Network.NoProxy),\n\t\tType: c.Network.Proxy.Type,\n\t\tServer: ExpandVars(c.Network.Proxy.Server),\n\t\tPort: c.Network.Proxy.Port,\n\t\tUsername: ExpandVars(c.Network.Proxy.Username),\n\t\tPassword: ExpandVars(c.Network.Proxy.Password),\n\t\tDirectHosts: c.directProxyHosts(),\n\t}\n}\n\n// directProxyHosts collects the base_url hosts of providers marked no_proxy, so\n// netclient bypasses the proxy for them without knowing any provider by name.\n//\n// Only for an auto-detected proxy (auto/env): that proxy is typically a\n// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep\n// them direct. An explicit proxy_mode = \"custom\" is the user saying \"route\n// everything through this\" — e.g. a mandatory corporate proxy — so honor it for\n// every provider; a custom-proxy user who wants a host direct uses\n// network.no_proxy instead (#3635).\nfunc (c *Config) directProxyHosts() []string {\n\tif c.NetworkProxyMode() == netclient.ModeCustom {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, p := range c.Providers {\n\t\tif !p.NoProxy {\n\t\t\tcontinue\n\t\t}\n\t\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif h := u.Hostname(); h != \"\" && !seen[h] {\n\t\t\tseen[h] = true\n\t\t\tout = append(out, h)\n\t\t}\n\t}\n\treturn out\n}\n\n// NetworkProxyMode normalizes network.proxy_mode to a known value.\nfunc (c *Config) NetworkProxyMode() string {\n\treturn netclient.NormalizeMode(c.Network.ProxyMode)\n}\n\n// SkillsConfig configures skill discovery. Paths adds extra \"custom\"-scope skill\n// roots — each a directory of SKILL.md / .md playbooks — scanned between\n// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and\n// the global roots. ExcludedPaths hides matching discovery roots without deleting\n// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills\n// hides named skills from the agent prompt, slash invocation, and skill tools\n// while keeping them manageable.\ntype SkillsConfig struct {\n\tPaths []string `toml:\"paths\"`\n\tExcludedPaths []string `toml:\"excluded_paths\"`\n\tDisabledSkills []string `toml:\"disabled_skills\"`\n\tMaxDepth int `toml:\"max_depth\"`\n}\n\n// SkillCustomPaths returns the configured custom skill roots with ${VAR}\n// expanded; empty entries are dropped.\nfunc (c *Config) SkillCustomPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.Paths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillExcludedPaths returns configured skill roots that should be hidden from\n// discovery, with ${VAR} expanded and empty entries dropped.\nfunc (c *Config) SkillExcludedPaths() []string {\n\tvar out []string\n\tfor _, p := range c.Skills.ExcludedPaths {\n\t\tif p = ExpandVars(p); strings.TrimSpace(p) != \"\" {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill\n// packs while Store keeps nested markdown safe by requiring descriptions.\nfunc (c *Config) SkillMaxDepth() int {\n\tconst (\n\t\tdefaultDepth = 3\n\t\tmaxDepth = 5\n\t)\n\tif c == nil || c.Skills.MaxDepth == 0 {\n\t\treturn defaultDepth\n\t}\n\tif c.Skills.MaxDepth < 1 {\n\t\treturn 1\n\t}\n\tif c.Skills.MaxDepth > maxDepth {\n\t\treturn maxDepth\n\t}\n\treturn c.Skills.MaxDepth\n}\n\n// DisabledSkillNames returns valid disabled skill identifiers, preserving the\n// first spelling and dropping duplicates/empty entries.\nfunc (c *Config) DisabledSkillNames() []string {\n\tseen := map[string]bool{}\n\tvar out []string\n\tfor _, name := range c.Skills.DisabledSkills {\n\t\tname = strings.TrimSpace(name)\n\t\tif !IsValidSkillName(name) {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SkillNameKey(name)\n\t\tif seen[key] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = true\n\t\tout = append(out, name)\n\t}\n\treturn out\n}\n\n// IsSkillDisabled reports whether name is configured as disabled.\nfunc (c *Config) IsSkillDisabled(name string) bool {\n\tkey := SkillNameKey(name)\n\tif key == \"\" {\n\t\treturn false\n\t}\n\tfor _, disabled := range c.DisabledSkillNames() {\n\t\tif SkillNameKey(disabled) == key {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer\n// confinement). WorkspaceRoot is the directory the built-in file writers\n// (write_file / edit_file / multi_edit / move_file) may modify; empty means the\n// current working directory, so writes stay inside the project by default.\n// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo\n// or a temp dir). Both support ${VAR} / ${VAR:-default} expansion. Reads are\n// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).\ntype SandboxConfig struct {\n\tWorkspaceRoot string `toml:\"workspace_root\"`\n\tAllowWrite []string `toml:\"allow_write\"`\n\t// Bash is the OS-sandbox mode for the bash tool: \"enforce\" (default) jails\n\t// each command, \"off\" runs it unconfined. Phase 1; macOS only for now, with\n\t// a graceful fallback elsewhere (see internal/sandbox).\n\tBash string `toml:\"bash\"`\n\t// Network allows network egress from inside the bash sandbox. Defaults true\n\t// so module/package downloads keep working; the boundary is then writes.\n\tNetwork bool `toml:\"network\"`\n}\n\n// WriteRoots returns the directories file-writer tools may modify: the\n// workspace root (defaulting to the current working directory when unset), plus\n// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given\n// (relative or absolute); the confiner resolves them to absolute, symlink-free\n// paths. The result is always non-empty, so confinement is on by default.\nfunc (c *Config) WriteRoots() []string {\n\treturn c.WriteRootsForRoot(\".\")\n}\n\n// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the\n// config doesn't explicitly set a workspace_root. Desktop tabs pass their\n// project root here so tool confinement is correct without changing cwd.\nfunc (c *Config) WriteRootsForRoot(fallbackRoot string) []string {\n\troot := ExpandVars(c.Sandbox.WorkspaceRoot)\n\tif root == \"\" {\n\t\troot = fallbackRoot\n\t\tif root == \"\" || root == \".\" {\n\t\t\tif wd, err := os.Getwd(); err == nil {\n\t\t\t\troot = wd\n\t\t\t} else {\n\t\t\t\troot = \".\"\n\t\t\t}\n\t\t}\n\t}\n\troots := []string{root}\n\tfor _, d := range c.Sandbox.AllowWrite {\n\t\tif d = ExpandVars(d); d != \"\" {\n\t\t\troots = append(roots, d)\n\t\t}\n\t}\n\treturn roots\n}\n\n// BashMode normalises the bash-sandbox mode: only an explicit \"off\" disables\n// it; empty or any other value resolves to \"enforce\", so the sandbox is on by\n// default and fails safe.\nfunc (c *Config) BashMode() string {\n\tif c.Sandbox.Bash == \"off\" {\n\t\treturn \"off\"\n\t}\n\treturn \"enforce\"\n}\n\n// AgentConfig configures the harness loop. PlannerModel is optional: when set\n// to another provider's name it enables two-model collaboration, where the\n// planner handles low-frequency planning in its own session (kept separate so\n// each model's prompt prefix stays cache-stable). SubagentModel is the optional\n// default for runAs=subagent skills; SubagentModels overrides it per skill name.\ntype AgentConfig struct {\n\tSystemPrompt string `toml:\"system_prompt\"`\n\tSystemPromptFile string `toml:\"system_prompt_file\"`\n\tMaxSteps int `toml:\"max_steps\"` // tool-call rounds per turn; 0 = unlimited\n\tPlannerMaxSteps int `toml:\"planner_max_steps\"` // planner read-only tool-call rounds; 0 = unlimited\n\tTemperature float64 `toml:\"temperature\"`\n\tPlannerModel string `toml:\"planner_model\"`\n\tSubagentModel string `toml:\"subagent_model\"`\n\tSubagentModels map[string]string `toml:\"subagent_models\"`\n\tSubagentEffort string `toml:\"subagent_effort\"`\n\tSubagentEfforts map[string]string `toml:\"subagent_efforts\"`\n\t// OutputStyle selects a persona/tone block folded into the system prompt at\n\t// startup (a built-in like \"explanatory\"/\"learning\"/\"concise\", or a custom\n\t// .reasonix/output-styles/.md). Empty = the unmodified prompt.\n\tOutputStyle string `toml:\"output_style\"`\n\t// AutoPlan controls whether interactive turns that look multi-step start in\n\t// plan mode automatically: \"off\" keeps plan mode manual, \"on\" enables the\n\t// approval gate. Legacy \"ask\" is treated as \"on\".\n\tAutoPlan string `toml:\"auto_plan\"`\n\t// ReasoningLanguage controls the preferred language for visible reasoning\n\t// text. Empty/auto follows the conversation language. Applied as transient\n\t// turn context, not the stable prompt.\n\tReasoningLanguage string `toml:\"reasoning_language\"`\n\t// AutoPlanClassifier optionally names a provider/model used to classify\n\t// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.\n\tAutoPlanClassifier string `toml:\"auto_plan_classifier\"`\n\t// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.\n\tSoftCompactRatio float64 `toml:\"soft_compact_ratio\"`\n\tCompactRatio float64 `toml:\"compact_ratio\"`\n\tCompactForceRatio float64 `toml:\"compact_force_ratio\"`\n\t// Keep controls which compactable messages stay verbatim beyond the current\n\t// user-fact/digest floor and recent tail. Empty uses the conservative default\n\t// of keeping error tool results.\n\tKeep []string `toml:\"keep\"`\n\tRecentKeep int `toml:\"recent_keep\"`\n\t// ColdResumePrune elides stale tool results when a session reopens past the\n\t// provider cache window. nil = default enabled.\n\tColdResumePrune *bool `toml:\"cold_resume_prune\"`\n\t// PlanModeAllowedTools names tools that are exempt from the plan-mode read-only\n\t// gate. When a tool named here is called while in plan mode, it executes without\n\t// the \"plan mode is read-only\" block. Use sparingly — prefer the built-in safe\n\t// bash commands for read-only exploration.\n\tPlanModeAllowedTools []string `toml:\"plan_mode_allowed_tools\"`\n}\n\n// ProviderEntry declares a model provider instance. ContextWindow is the model's\n// token budget; the harness compacts older history as a turn's prompt approaches\n// it (see agent compaction). 0 disables compaction for the instance.\ntype ProviderEntry struct {\n\tName string `toml:\"name\"`\n\tKind string `toml:\"kind\"`\n\tBaseURL string `toml:\"base_url\"`\n\tModel string `toml:\"model\"` // a single model (back-compat)\n\tModels []string `toml:\"models\"` // a vendor's model list (one base_url/key, many models)\n\tModelsURL string `toml:\"models_url\"` // auto-fetch models from this URL on startup\n\tDefault string `toml:\"default\"` // default model when Models is set (else Models[0])\n\tAPIKeyEnv string `toml:\"api_key_env\"`\n\tBalanceURL string `toml:\"balance_url\"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.\n\tContextWindow int `toml:\"context_window\"`\n\tPrice *provider.Pricing `toml:\"price\"` // legacy/provider-wide fallback\n\tPrices map[string]*provider.Pricing `toml:\"prices\"` // optional per-model prices; keys are model ids\n\t// Thinking / Effort are provider-kind-specific knobs forwarded to the provider\n\t// via Config.Extra. The anthropic provider reads Thinking=\"adaptive\" to enable\n\t// extended thinking and Effort (\"low\"..\"max\") to tune depth. The\n\t// openai-compatible provider forwards Effort as reasoning_effort for\n\t// thinking-capable models; DeepSeek accepts high|max.\n\t// Empty = provider default.\n\tThinking string `toml:\"thinking\"`\n\tEffort string `toml:\"effort\"`\n\t// Vision marks the model as accepting image input. When set, images the user\n\t// attaches are embedded in the request (image_url for openai-kind, base64\n\t// blocks for anthropic). Off by default: text-only models 400 on image input,\n\t// and image tokens are heavy — gating keeps text-only flows cheap (the prompt\n\t// prefix is byte-identical with no image, so the cache is unaffected either way).\n\tVision bool `toml:\"vision\"`\n\t// VisionModels narrows image input support to specific models in a multi-model\n\t// provider. This lets one provider expose both text-only and multimodal chat\n\t// models without enabling image payloads for every model.\n\tVisionModels []string `toml:\"vision_models\"`\n\t// VisionDetail sets the openai image_url detail hint (low|high); empty = auto\n\t// (the field is omitted). \"low\" caps an image to a fixed ~85 tokens for cheap\n\t// coarse reads; ignored by providers without the knob (e.g. anthropic).\n\tVisionDetail string `toml:\"vision_detail\"`\n\t// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning\n\t// models. Empty/auto uses the model capability registry plus endpoint\n\t// heuristics; none disables automatic reasoning controls for this provider.\n\tReasoningProtocol string `toml:\"reasoning_protocol\"`\n\t// SupportedEfforts lists the /effort levels this provider/model exposes.\n\t// When non-empty, it overrides the built-in defaults derived from\n\t// Kind/BaseURL and makes /effort configurable. \"auto\" is the implicit\n\t// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort\n\t// (or set one outside this list) to fall back to SupportedEfforts[0].\n\tSupportedEfforts []string `toml:\"supported_efforts\"`\n\t// DefaultEffort is the /effort level used when the user picks \"auto\" or\n\t// has not set Effort. Ignored when SupportedEfforts is empty.\n\tDefaultEffort string `toml:\"default_effort\"`\n\t// NoProxy reaches this provider's base_url directly, never through the proxy.\n\t// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).\n\tNoProxy bool `toml:\"no_proxy\"`\n}\n\n// ModelList returns the models this provider exposes: the explicit `models` list,\n// or the single `model` as a one-element list (back-compat). Empty if neither set.\nfunc (e *ProviderEntry) ModelList() []string {\n\tif len(e.Models) > 0 {\n\t\treturn e.Models\n\t}\n\tif e.Model != \"\" {\n\t\treturn []string{e.Model}\n\t}\n\treturn nil\n}\n\n// IsLikelyChatModel reports whether a model ID looks like a chat/completion\n// model rather than a specialised audio/vision/embedding model. It applies a\n// conservative name-based heuristic — the OpenAI-compatible /models API does\n// not return capability/modality metadata, so this is the most reliable\n// fallback until providers add such fields.\n//\n// The heuristic works in two passes:\n// 1. Multi-word substring check for compound terms that span separators\n// (e.g. \"text-embedding\", \"text-to-speech\").\n// 2. Token-level check: the model ID is split on common separators (- _ . / :)\n// and each token is compared against a set of known non-chat keywords.\n//\n// \"voice\" is intentionally absent from the non-chat set because it is too\n// broad — legitimate future chat models may include it in their name.\nfunc IsLikelyChatModel(model string) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" {\n\t\treturn false\n\t}\n\tlower := strings.ToLower(model)\n\n\t// Pass 1: compound terms that span separator boundaries.\n\tvar compoundNonChat = []string{\n\t\t\"text-embedding\", \"text-to-speech\", \"speech-to-text\",\n\t}\n\tfor _, c := range compoundNonChat {\n\t\tif strings.Contains(lower, c) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Pass 2: token-level check.\n\ttokens := strings.FieldsFunc(lower, func(r rune) bool {\n\t\treturn r == '-' || r == '_' || r == '.' || r == '/' || r == ':'\n\t})\n\tvar nonChatTokens = map[string]bool{\n\t\t\"asr\": true, \"stt\": true, \"tts\": true,\n\t\t\"whisper\": true, \"embedding\": true,\n\t\t\"moderation\": true, \"rerank\": true, \"dall\": true,\n\t\t\"transcription\": true,\n\t}\n\tfor _, tok := range tokens {\n\t\tif nonChatTokens[tok] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ChatModelList returns ModelList filtered to likely chat/completion models.\n// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do\n// not appear in the chat model picker. Use ModelList() only when the full\n// raw provider model list is needed, such as config serialization, provider\n// diagnostics, or model-fetch editing.\nfunc (e *ProviderEntry) ChatModelList() []string {\n\traw := e.ModelList()\n\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tout := make([]string, 0, len(raw))\n\tfor _, m := range raw {\n\t\tif IsLikelyChatModel(m) {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultModel returns the provider's default model: the explicit `default`, else\n// the first of ModelList.\nfunc (e *ProviderEntry) DefaultModel() string {\n\tif e.Default != \"\" {\n\t\treturn e.Default\n\t}\n\tif l := e.ModelList(); len(l) > 0 {\n\t\treturn l[0]\n\t}\n\treturn \"\"\n}\n\n// HasModel reports whether m is one of the provider's models.\nfunc (e *ProviderEntry) HasModel(m string) bool {\n\tfor _, x := range e.ModelList() {\n\t\tif x == m {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// PriceForModel returns the configured per-1M-token price for model. Per-model\n// prices win; the legacy provider-wide price is a fallback for older configs.\nfunc (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {\n\tif e == nil {\n\t\treturn nil\n\t}\n\tif e.Prices != nil {\n\t\tif p := e.Prices[strings.TrimSpace(model)]; p != nil {\n\t\t\treturn clonePricing(p)\n\t\t}\n\t}\n\treturn clonePricing(e.Price)\n}\n\nfunc (e *ProviderEntry) applyModelPrice() {\n\tif e == nil {\n\t\treturn\n\t}\n\te.Price = e.PriceForModel(e.Model)\n}\n\nfunc clonePricing(p *provider.Pricing) *provider.Pricing {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tcp := *p\n\treturn &cp\n}\n\n// ToolsConfig selects which built-in tools are enabled. Empty means all of them.\ntype ToolsConfig struct {\n\tEnabled []string `toml:\"enabled\"`\n\tBashTimeoutSeconds *int `toml:\"bash_timeout_seconds\"`\n\tBackgroundJobs BackgroundJobsConfig `toml:\"background_jobs\"`\n\tSearch SearchConfig `toml:\"search\"`\n\tShell ShellConfig `toml:\"shell\"`\n}\n\nconst (\n\tdefaultBashTimeoutSeconds = 120\n\tdefaultBackgroundJobStalledWarningSec = 900\n\tmaxBackgroundJobStalledWarningSec = 86400\n)\n\n// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted\n// config keeps the historical 120s safety cap, explicit 0 disables the\n// tool-local cap, and positive values set a custom cap. Negative values fall\n// back to the default so a typo cannot silently remove the safety net.\nfunc (c *Config) BashTimeoutSeconds() int {\n\tif c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {\n\t\treturn defaultBashTimeoutSeconds\n\t}\n\treturn *c.Tools.BashTimeoutSeconds\n}\n\n// BackgroundJobsConfig tunes parent-created background jobs.\ntype BackgroundJobsConfig struct {\n\tStalledWarningSeconds *int `toml:\"stalled_warning_seconds\"`\n}\n\n// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in\n// seconds. Omitted/negative values keep the default, explicit 0 disables the\n// notice, and oversized values clamp to one day so a typo cannot become\n// effectively invisible.\nfunc (c *Config) BackgroundJobStalledWarningSeconds() int {\n\tif c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {\n\t\treturn defaultBackgroundJobStalledWarningSec\n\t}\n\tif *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {\n\t\treturn maxBackgroundJobStalledWarningSec\n\t}\n\treturn *c.Tools.BackgroundJobs.StalledWarningSeconds\n}\n\n// SearchConfig tunes the grep tool's engine. Engine is \"auto\" (default — use\n// ripgrep when it's on PATH, else the native Go scanner), \"native\" (always Go),\n// or \"rg\" (require ripgrep; warn at startup and fall back to native if absent).\n// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.\ntype SearchConfig struct {\n\tEngine string `toml:\"engine\"`\n\tRgPath string `toml:\"rg_path\"`\n}\n\n// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer\n// is \"auto\" (default — real bash when present, else PowerShell on Windows),\n// \"bash\", or \"powershell\"/\"pwsh\" (force it; warn at startup and fall back to\n// auto if absent). Path optionally points at a specific shell executable.\ntype ShellConfig struct {\n\tPrefer string `toml:\"prefer\"`\n\tPath string `toml:\"path\"`\n}\n\n// PermissionsConfig declares the per-call permission policy (see\n// internal/permission). Mode is the fallback decision for writer tools when no\n// rule matches (\"ask\" | \"allow\" | \"deny\"; default \"ask\"); read-only tools always\n// fall back to allow. Allow/Ask/Deny are rule lists of the form \"ToolName\" or\n// \"ToolName(glob)\". Precedence: deny > ask > allow > fallback.\ntype PermissionsConfig struct {\n\tMode string `toml:\"mode\"`\n\tAllow []string `toml:\"allow\"`\n\tAsk []string `toml:\"ask\"`\n\tDeny []string `toml:\"deny\"`\n}\n\n// PluginEntry declares an external MCP server. Type selects the transport:\n// \"stdio\" (default) launches Command/Args/Env as a subprocess; \"http\"\n// (a.k.a. streamable-http) and \"sse\" connect to a remote URL with optional\n// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so\n// secrets (bearer tokens, keys) come from the environment, not the file. The\n// fields mirror Claude Code's mcpServers spec, so entries can come from either\n// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).\ntype PluginEntry struct {\n\tName string `toml:\"name\"`\n\tType string `toml:\"type\"` // \"stdio\" (default) | \"http\" | \"sse\"\n\tCommand string `toml:\"command\"`\n\tArgs []string `toml:\"args\"`\n\tEnv map[string]string `toml:\"env\"`\n\tURL string `toml:\"url\"`\n\tHeaders map[string]string `toml:\"headers\"`\n\t// AutoStart controls whether the server connects during session startup.\n\t// Nil preserves historical behavior: configured servers start automatically.\n\tAutoStart *bool `toml:\"auto_start\"`\n\t// Tier selects how aggressively the server is connected at boot:\n\t// \"eager\" — blocks startup until the handshake completes; required for\n\t// servers whose tools the system prompt depends on.\n\t// \"lazy\" — registers placeholder tools immediately (from on-disk\n\t// schema cache when available) and only spawns the real\n\t// subprocess on first model use. Kept for legacy configs.\n\t// \"background\" — placeholder + spawn fired at boot but not waited on;\n\t// swap happens once the spawn finishes.\n\t// Empty defaults to \"background\" so enabled MCPs connect automatically\n\t// without blocking chat. Unknown non-empty values fall back to \"lazy\".\n\tTier string `toml:\"tier\"`\n}\n\nfunc (e PluginEntry) ShouldAutoStart() bool {\n\treturn e.AutoStart == nil || *e.AutoStart\n}\n\n// ResolvedTier returns the normalized tier (\"eager\"|\"lazy\"|\"background\") with\n// the project default applied. Unknown values fall back to \"lazy\" so a typo\n// never forces a slow boot.\nfunc (e PluginEntry) ResolvedTier() string {\n\treturn resolvedMCPTier(e.Tier)\n}\n\nfunc resolvedMCPTier(tier string) string {\n\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n\tcase \"eager\":\n\t\treturn \"eager\"\n\tcase \"background\":\n\t\treturn \"background\"\n\tcase \"\":\n\t\treturn \"background\"\n\tdefault:\n\t\treturn \"lazy\"\n\t}\n}\n\nfunc (c *Config) AutoStartPlugins() []PluginEntry {\n\tout := make([]PluginEntry, 0, len(c.Plugins))\n\tfor _, p := range c.Plugins {\n\t\tif p.ShouldAutoStart() {\n\t\t\tout = append(out, p)\n\t\t}\n\t}\n\treturn out\n}\n\n// DefaultSystemPrompt is used when config provides none.\nconst DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm. Approval-bypass\nmodes do not answer ask questions or approve plans for the user. If no\ninteractive user is available, the ask tool returns a model-assumption fallback;\nstate the assumption you made before proceeding.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.`\n\n// LanguagePolicy is the auto fallback appended to the system prompt when no\n// concrete UI language is resolved. It is static English text, so it stays part\n// of the cache-stable prefix and avoids per-turn language injection.\nconst LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +\n\t`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +\n\t`whenever they switch. Let this also guide the language you think in. Always keep code, ` +\n\t`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`\n\n// Default returns the built-in default configuration (DeepSeek + MiMo presets).\nfunc Default() *Config {\n\treturn &Config{\n\t\tConfigVersion: 3,\n\t\tDefaultModel: \"deepseek-flash\",\n\t\tCredentialsStore: CredentialsStoreAuto,\n\t\tUI: UIConfig{Theme: \"auto\"},\n\t\tNotifications: NotificationsConfig{\n\t\t\tEnabled: false,\n\t\t\tTurnDone: true,\n\t\t\tApprovalRequest: true,\n\t\t\tAskRequest: true,\n\t\t},\n\t\tAgent: AgentConfig{\n\t\t\tSystemPrompt: DefaultSystemPrompt,\n\t\t\t// 0 = no step cap: the agent loops until the model gives a final answer,\n\t\t\t// the user cancels, or the provider errors. Context stays bounded by\n\t\t\t// compaction, not by a round count. Set a positive agent.max_steps only\n\t\t\t// if you want a hard guard against runaway.\n\t\t\tMaxSteps: 0,\n\t\t\tPlannerMaxSteps: 12,\n\t\t\tAutoPlan: \"off\",\n\t\t\tSoftCompactRatio: 0.5,\n\t\t\tCompactRatio: 0.8,\n\t\t\tCompactForceRatio: 0.9,\n\t\t},\n\t\t// Mode \"ask\" with no rules keeps `reasonix run` autonomous (no TTY → ask\n\t\t// resolves to allow) while `reasonix` prompts before writers. Users add\n\t\t// deny/allow rules to harden or quiet specific tools.\n\t\tPermissions: PermissionsConfig{Mode: \"ask\"},\n\t\t// Sandbox on by default: bash is jailed (macOS), network allowed so\n\t\t// builds/downloads work. Set bash = \"off\" to disable. Network=true here\n\t\t// so an absent [sandbox] in a user's file keeps egress (zero value would\n\t\t// wrongly deny it).\n\t\tSandbox: SandboxConfig{Bash: \"enforce\", Network: true},\n\t\t// LSP tools on by default, but dormant until a language server is on PATH;\n\t\t// a missing server yields an install hint rather than an error.\n\t\tLSP: LSPConfig{Enabled: true},\n\t\tNetwork: NetworkConfig{ProxyMode: netclient.ModeAuto},\n\t\tBot: BotConfig{\n\t\t\tToolApprovalMode: \"ask\",\n\t\t\tMaxSteps: 25,\n\t\t\tDebounceMs: 1500,\n\t\t\tAllowlist: BotAllowlist{Enabled: true},\n\t\t\tQQ: QQBotConfig{AppSecretEnv: \"QQ_BOT_APP_SECRET\"},\n\t\t\tFeishu: FeishuBotConfig{Domain: \"feishu\", AppSecretEnv: \"FEISHU_BOT_APP_SECRET\", Mode: \"webhook\", WebhookPort: 8080, RequireMention: true},\n\t\t\tWeixin: WeixinBotConfig{AccountID: \"default\", TokenEnv: \"WEIXIN_BOT_TOKEN\", APIBase: \"https://ilinkai.weixin.qq.com\"},\n\t\t},\n\t\tProviders: []ProviderEntry{\n\t\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},\n\t\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", APIKeyEnv: \"DEEPSEEK_API_KEY\", BalanceURL: \"https://api.deepseek.com/user/balance\", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},\n\t\t\t{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true},\n\t\t\t{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true},\n\t\t},\n\t}\n}\n\nfunc deepSeekV4FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc deepSeekV4ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc deepSeekV4Prices() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPrice(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPrice(),\n\t}\n}\n\nfunc deepSeekV4FlashPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"}\n}\n\nfunc deepSeekV4ProPriceUSD() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"}\n}\n\nfunc deepSeekV4PricesUSD() map[string]*provider.Pricing {\n\treturn map[string]*provider.Pricing{\n\t\t\"deepseek-v4-flash\": deepSeekV4FlashPriceUSD(),\n\t\t\"deepseek-v4-pro\": deepSeekV4ProPriceUSD(),\n\t}\n}\n\n// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while\n// official DeepSeek defaults move to RMB. Persisted prices still win; this is\n// only used for templates and missing-default backfills.\nfunc DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {\n\t_ = lang\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {\n\t_ = c\n\treturn deepSeekV4Prices()\n}\n\nfunc deepSeekV4PriceForModel(lang, model string) *provider.Pricing {\n\t_ = lang\n\treturn clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])\n}\n\n// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.\n// Official DeepSeek providers now seed RMB prices by default; explicit user\n// prices in config still override these defaults.\nfunc (c *Config) DeepSeekOfficialPricingLanguage() string {\n\t_ = c\n\treturn \"zh\"\n}\n\n// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek\n// prices that still match known official defaults. Custom user prices are left\n// untouched.\nfunc (c *Config) ApplyDeepSeekOfficialDefaultPricing() {\n\tapplyDeepSeekOfficialDefaultPricing(c)\n}\n\nfunc applyDeepSeekOfficialDefaultPricing(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tlang := c.DeepSeekOfficialPricingLanguage()\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif isKnownDeepSeekOfficialPricing(p.Model, p.Price) {\n\t\t\tp.Price = deepSeekV4PriceForModel(lang, p.Model)\n\t\t}\n\t\tfor model, price := range p.Prices {\n\t\t\tif isKnownDeepSeekOfficialPricing(model, price) {\n\t\t\t\tp.Prices[model] = deepSeekV4PriceForModel(lang, model)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc mimoV25ProPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: \"¥\"}\n}\n\nfunc mimoV25Price() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: \"¥\"}\n}\n\nfunc mimoV2FlashPrice() *provider.Pricing {\n\treturn &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: \"¥\"}\n}\n\nfunc mimoDomesticPrices(models []string) map[string]*provider.Pricing {\n\tprices := map[string]*provider.Pricing{}\n\tfor _, model := range models {\n\t\tswitch strings.TrimSpace(model) {\n\t\tcase \"mimo-v2.5-pro\", \"mimo-v2-pro\":\n\t\t\tprices[model] = mimoV25ProPrice()\n\t\tcase \"mimo-v2.5\", \"mimo-v2-omni\":\n\t\t\tprices[model] = mimoV25Price()\n\t\tcase \"mimo-v2-flash\":\n\t\t\tprices[model] = mimoV2FlashPrice()\n\t\t}\n\t}\n\treturn prices\n}\n\nfunc backfillMimoDomesticPrices(e *ProviderEntry) {\n\tif e == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(e.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tif e.Prices == nil {\n\t\te.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif e.Prices[model] == nil {\n\t\t\te.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\n// ResetOfficialProviderPricingOnUpgrade resets official DeepSeek/MiMo prices to\n// the current built-in RMB defaults once for desktop upgrades. It intentionally\n// runs from the desktop app startup path, not every config Load(), so user edits\n// made after the upgrade are preserved.\nfunc ResetOfficialProviderPricingOnUpgrade(path string) (bool, error) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn false, nil\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\tvar header Config\n\tif _, err := toml.DecodeFile(path, &header); err != nil {\n\t\treturn false, fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\tif header.ConfigVersion >= Default().ConfigVersion {\n\t\treturn false, nil\n\t}\n\tcfg := LoadForEdit(path)\n\tresetOfficialProviderPricingDefaults(cfg)\n\tcfg.ConfigVersion = Default().ConfigVersion\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc resetOfficialProviderPricingDefaults(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tswitch {\n\t\tcase officialProviderKind(p) == \"deepseek\":\n\t\t\tresetDeepSeekOfficialPricing(p)\n\t\tcase isOfficialMimoAPIProvider(p), isOfficialMimoTokenPlanProvider(p):\n\t\t\tresetMimoOfficialPricing(p)\n\t\t}\n\t}\n}\n\nfunc resetDeepSeekOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4Prices()\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tif p.Prices == nil {\n\t\tp.Prices = map[string]*provider.Pricing{}\n\t}\n\tfor model, price := range defaults {\n\t\tif p.HasModel(model) {\n\t\t\tp.Prices[model] = clonePricing(price)\n\t\t}\n\t}\n}\n\nfunc resetMimoOfficialPricing(p *ProviderEntry) {\n\tif p == nil {\n\t\treturn\n\t}\n\tdefaults := mimoDomesticPrices(p.ModelList())\n\tif len(defaults) == 0 {\n\t\treturn\n\t}\n\tp.Price = nil\n\tif strings.TrimSpace(p.Model) != \"\" && len(p.Models) == 0 {\n\t\tif price := defaults[strings.TrimSpace(p.Model)]; price != nil {\n\t\t\tp.Price = clonePricing(price)\n\t\t\tp.Prices = nil\n\t\t\treturn\n\t\t}\n\t}\n\tp.Prices = map[string]*provider.Pricing{}\n\tfor model, price := range defaults {\n\t\tp.Prices[model] = clonePricing(price)\n\t}\n}\n\nfunc isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {\n\tmodel = strings.TrimSpace(model)\n\tif model == \"\" || price == nil {\n\t\treturn false\n\t}\n\tfor _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {\n\t\tif samePricing(price, prices[model]) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc samePricing(a, b *provider.Pricing) bool {\n\tif a == nil || b == nil {\n\t\treturn false\n\t}\n\treturn a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency\n}\n\n// Load builds the configuration: defaults, then user config, then project\n// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority)\n// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory\n// is loaded first so api_key_env can resolve.\nfunc Load() (*Config, error) {\n\treturn LoadForRoot(\".\")\n}\n\n// LoadForRoot builds the configuration with project files resolved from root\n// instead of the current working directory. When root is \"\" or \".\", it behaves\n// like Load(). This is the workspace-aware entry point: desktop tabs use it so\n// each project's reasonix.toml + .env + .mcp.json are resolved independently\n// without changing the process cwd.\nfunc LoadForRoot(root string) (*Config, error) {\n\troot = resolveRoot(root)\n\tloadDotEnvForRoot(root)\n\tcfg := Default()\n\tcfg.CredentialsStore = credentialsStoreMode()\n\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\n\tvar tomlSources []string\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\ttomlSources = append(tomlSources, uc)\n\t}\n\ttomlSources = append(tomlSources, projectTOML)\n\tfor _, path := range tomlSources {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\t\tslog.Warn(\"config: legacy mcp tier migration failed\", \"path\", path, \"err\", err)\n\t\t\t}\n\t\t}\n\t\tif err := mergeFile(cfg, path); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\t// toml.DecodeFile replaces [[plugins]] wholesale, so cfg.Plugins now holds\n\t// only the last file's. Re-merge by name across all sources (later wins) so a\n\t// project reasonix.toml doesn't drop the global config's MCP servers.\n\tplugins, err := mergeTOMLPlugins(tomlSources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.Plugins = plugins\n\tif providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Providers = providers\n\t\tcfg.providerSources = providerSources\n\t\tcfg.shadowedProjectProviders = shadowedProjectProviders\n\t}\n\tif access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil {\n\t\treturn nil, err\n\t} else if ok {\n\t\tcfg.Desktop.ProviderAccess = access\n\t}\n\n\t// Claude Code's .mcp.json (project root) is read last and merged into\n\t// [[plugins]], so a server configured for Claude works here unchanged.\n\t// reasonix.toml wins on a name collision (see mergeMCPJSON).\n\tmcpFile := mcpJSONFile\n\tif root != \".\" {\n\t\tmcpFile = filepath.Join(root, mcpJSONFile)\n\t}\n\tentries, err := loadMCPJSON(mcpFile)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcfg.mergeMCPJSON(entries)\n\n\t// Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading\n\t// from the TypeScript line keeps MCP servers without rewriting them. Anything\n\t// the v2 config or .mcp.json already declared wins on a name collision.\n\tcfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath()))\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tnormalizeOfficialDeepSeekModels(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n\tbackfillDeepSeekPro(cfg)\n\tcfg.CredentialsStore = credentialsStoreMode()\n\treturn cfg, nil\n}\n\n// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard\n// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro\n// SKU, leaving users unable to switch to it. In-memory only — the user's file is\n// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to\n// serve pro) so a custom flash-only deployment isn't given an entry that 404s.\nfunc backfillDeepSeekPro(c *Config) {\n\tconst flashModel, proModel = \"deepseek-v4-flash\", \"deepseek-v4-pro\"\n\tvar flash *ProviderEntry\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, m := range p.ModelList() {\n\t\t\tswitch m {\n\t\t\tcase proModel:\n\t\t\t\treturn // pro already reachable\n\t\t\tcase flashModel:\n\t\t\t\tif strings.Contains(p.BaseURL, \"api.deepseek.com\") {\n\t\t\t\t\tflash = p\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif flash == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list for the flash provider\n\t// (e.g. unchecked pro in Settings), respect that choice and do not backfill.\n\tif len(flash.Models) > 0 {\n\t\treturn\n\t}\n\tfor _, bp := range Default().Providers {\n\t\tif bp.Name == \"deepseek-pro\" {\n\t\t\tbp.APIKeyEnv = flash.APIKeyEnv\n\t\t\tbp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)\n\t\t\tc.Providers = append(c.Providers, bp)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc backfillDeepSeekOfficialPrices(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tdefaults := deepSeekV4PricesForConfig(c)\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderKind(p) != \"deepseek\" {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Price != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.Prices == nil {\n\t\t\tp.Prices = map[string]*provider.Pricing{}\n\t\t}\n\t\tfor model, price := range defaults {\n\t\t\tif p.HasModel(model) && p.Prices[model] == nil {\n\t\t\t\tp.Prices[model] = clonePricing(price)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc officialProviderKind(p *ProviderEntry) string {\n\tif p == nil {\n\t\treturn \"\"\n\t}\n\tu, err := url.Parse(strings.TrimSpace(p.BaseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tif strings.EqualFold(u.Hostname(), \"api.deepseek.com\") {\n\t\treturn \"deepseek\"\n\t}\n\treturn \"\"\n}\n\nfunc resolveRoot(root string) string {\n\tif root == \"\" || root == \".\" {\n\t\treturn \".\"\n\t}\n\treturn filepath.Clean(root)\n}\n\n// normalizeLegacyEffort migrates the retired DeepSeek effort=\"off\" (the old\n// /thinking off that disabled thinking) to the provider default, so a config\n// written by an older version keeps loading instead of erroring on a value the\n// provider no longer accepts.\nfunc normalizeLegacyEffort(c *Config) {\n\tfor i := range c.Providers {\n\t\tif strings.EqualFold(strings.TrimSpace(c.Providers[i].Effort), \"off\") {\n\t\t\tc.Providers[i].Effort = \"\"\n\t\t}\n\t}\n}\n\n// mergeTOMLPlugins merges [[plugins]] across TOML sources by name (later source wins).\nfunc mergeTOMLPlugins(paths []string) ([]PluginEntry, error) {\n\tvar merged []PluginEntry\n\tindex := map[string]int{}\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tfor _, p := range f.Plugins {\n\t\t\tp, _ = NormalizePluginCommandLine(p)\n\t\t\tif i, ok := index[p.Name]; ok {\n\t\t\t\tmerged[i] = p\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex[p.Name] = len(merged)\n\t\t\tmerged = append(merged, p)\n\t\t}\n\t}\n\treturn merged, nil\n}\n\n// mergeTOMLProviders merges [[providers]] across TOML sources by provider name.\n// User-global providers win over same-named project providers; project providers\n// only fill names the global config does not define. Keep official legacy aliases\n// distinct here: they can carry different default models and effort capabilities,\n// and the later desktop normalization layer handles canonical Settings access.\nfunc mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) {\n\tvar merged []ProviderEntry\n\tvar shadowedProject []ProviderEntry\n\tindex := map[string]int{}\n\tsources := map[string]providerSourceScope{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tif _, err := toml.DecodeFile(path, &f); err != nil {\n\t\t\treturn nil, nil, nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif len(f.Providers) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tsource := providerSourceForPath(path)\n\t\tfor _, p := range f.Providers {\n\t\t\tnormalizeProviderEffortFields(&p)\n\t\t\tkey := providerMergeKey(p)\n\t\t\tif i, ok := index[key]; ok {\n\t\t\t\tif sources[key] == providerSourceProject && source == providerSourceUser {\n\t\t\t\t\tshadowedProject = append(shadowedProject, merged[i])\n\t\t\t\t\tmerged[i] = p\n\t\t\t\t\tsources[key] = source\n\t\t\t\t} else if sources[key] == providerSourceUser && source == providerSourceProject {\n\t\t\t\t\tshadowedProject = append(shadowedProject, p)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tindex[key] = len(merged)\n\t\t\t\tmerged = append(merged, p)\n\t\t\t\tsources[key] = source\n\t\t\t}\n\t\t}\n\t}\n\treturn merged, sources, shadowedProject, saw, nil\n}\n\nfunc providerSourceForPath(path string) providerSourceScope {\n\tif isUserConfigPath(path) {\n\t\treturn providerSourceUser\n\t}\n\treturn providerSourceProject\n}\n\nfunc providerMergeKey(p ProviderEntry) string {\n\treturn strings.TrimSpace(p.Name)\n}\n\n// mergeTOMLProviderAccess merges desktop.provider_access across TOML sources so\n// project desktop settings do not hide account-level providers from the desktop\n// model switcher.\nfunc mergeTOMLProviderAccess(paths []string) ([]string, bool, error) {\n\tvar merged []string\n\tseen := map[string]bool{}\n\tsaw := false\n\tfor _, path := range paths {\n\t\tif _, err := os.Stat(path); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tvar f Config\n\t\tmeta, err := toml.DecodeFile(path, &f)\n\t\tif err != nil {\n\t\t\treturn nil, false, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t\tif !meta.IsDefined(\"desktop\", \"provider_access\") {\n\t\t\tcontinue\n\t\t}\n\t\tsaw = true\n\t\tfor _, name := range f.Desktop.ProviderAccess {\n\t\t\tname = strings.TrimSpace(name)\n\t\t\tif name == \"\" || seen[name] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[name] = true\n\t\t\tmerged = append(merged, name)\n\t\t}\n\t}\n\treturn merged, saw, nil\n}\n\n// LoadForEdit returns a config to seed the `reasonix setup` wizard when reconfiguring:\n// the built-in defaults with the file at path (if present) decoded on top, so a\n// reconfigure preserves the user's existing providers and agent settings instead\n// of resetting to defaults. .env is loaded so api_key_env resolution works while\n// the wizard decides which keys are still missing.\nfunc LoadForEdit(path string) *Config {\n\tcfg, err := loadForEditStrict(path, true)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tloadDotEnvForEditPath(path)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc LoadForEditWithoutCredentials(path string) *Config {\n\tcfg, err := loadForEditStrict(path, false)\n\tif err == nil {\n\t\treturn cfg\n\t}\n\tslog.Warn(\"config: load for edit failed, using defaults\", \"path\", path, \"err\", err)\n\tcfg = Default()\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg\n}\n\nfunc loadForEditStrict(path string, loadCredentials bool) (*Config, error) {\n\tif loadCredentials {\n\t\tloadDotEnvForEditPath(path)\n\t}\n\tcfg := Default()\n\tif _, err := os.Stat(path); err == nil {\n\t\tif err := migrateLegacyMCPTiersFile(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"config %s: %w\", path, err)\n\t\t}\n\t}\n\tif err := mergeFile(cfg, path); err != nil {\n\t\treturn nil, err\n\t}\n\tnormalizeConfigForEdit(cfg)\n\treturn cfg, nil\n}\n\nfunc normalizeConfigForEdit(cfg *Config) {\n\tnormalizePluginCommandLines(cfg)\n\tnormalizeLegacyEffort(cfg)\n\tnormalizeLegacyMCPTiers(cfg)\n\tnormalizeLegacyProviderModels(cfg)\n\tnormalizeDesktopOfficialProviderAccess(cfg)\n\tapplyDeepSeekOfficialDefaultPricing(cfg)\n\tbackfillDeepSeekOfficialPrices(cfg)\n\tnormalizeEffortConfig(cfg)\n}\n\nfunc loadDotEnvForEditPath(path string) {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" || isUserConfigPath(path) {\n\t\tloadDotEnv()\n\t\treturn\n\t}\n\tloadDotEnvForRoot(filepath.Dir(path))\n}\n\n// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.\nfunc mergeFile(cfg *Config, path string) error {\n\tif _, err := os.Stat(path); err != nil {\n\t\treturn nil\n\t}\n\tif _, err := toml.DecodeFile(path, cfg); err != nil {\n\t\treturn fmt.Errorf(\"config %s: %w\", path, err)\n\t}\n\treturn nil\n}\n\n// normalizeLegacyMCPTiers keeps loaded legacy config files on the new product\n// behavior: enabled MCP servers connect in the background by default, and the\n// retired per-server startup tier is no longer a user-facing setting.\nfunc normalizeLegacyMCPTiers(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Plugins {\n\t\tc.Plugins[i].Tier = \"\"\n\t}\n}\n\nfunc migrateLegacyMCPTiersFile(path string) error {\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnext, changed := stripLegacyMCPTierLines(string(raw))\n\tif !changed {\n\t\treturn nil\n\t}\n\treturn os.WriteFile(path, []byte(next), info.Mode().Perm())\n}\n\nfunc stripLegacyMCPTierLines(raw string) (string, bool) {\n\tlines := strings.Split(raw, \"\\n\")\n\tsection := \"\"\n\tchanged := false\n\tout := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tif header := tomlSectionHeader(line); header != \"\" {\n\t\t\tsection = header\n\t\t}\n\t\tif section == \"plugins\" && isTOMLKeyAssignment(line, \"tier\") {\n\t\t\tchanged = true\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, line)\n\t}\n\treturn strings.Join(out, \"\\n\"), changed\n}\n\nfunc tomlSectionHeader(line string) string {\n\ttrimmed := strings.TrimSpace(line)\n\tif !strings.HasPrefix(trimmed, \"[\") {\n\t\treturn \"\"\n\t}\n\tif i := strings.Index(trimmed, \"#\"); i >= 0 {\n\t\ttrimmed = strings.TrimSpace(trimmed[:i])\n\t}\n\tswitch trimmed {\n\tcase \"[[plugins]]\":\n\t\treturn \"plugins\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc isTOMLKeyAssignment(line, key string) bool {\n\ttrimmed := strings.TrimSpace(line)\n\tif strings.HasPrefix(trimmed, \"#\") || !strings.HasPrefix(trimmed, key) {\n\t\treturn false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(trimmed, key))\n\treturn strings.HasPrefix(rest, \"=\")\n}\n\n// normalizeLegacyProviderModels repairs provider entries written by older\n// desktop builds that carried the official provider name/endpoint but omitted the\n// model field. The repair is intentionally narrow: valid user-provided model\n// lists are left untouched, while known official aliases get the model implied by\n// their preset name so model pickers and provider validation have an option.\nfunc normalizeLegacyProviderModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif providerHasAnyModel(*p) {\n\t\t\tcontinue\n\t\t}\n\t\tif model := legacyOfficialProviderModel(p.Name); model != \"\" {\n\t\t\tp.Model = model\n\t\t}\n\t}\n}\n\nfunc normalizeOfficialDeepSeekModels(c *Config) {\n\tif c == nil {\n\t\treturn\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif officialProviderHost(p.BaseURL) != \"api.deepseek.com\" {\n\t\t\tcontinue\n\t\t}\n\t\tswitch strings.TrimSpace(p.Name) {\n\t\tcase \"deepseek\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-flash\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-flash\"}, \"deepseek-v4-flash\")\n\t\tcase \"deepseek-pro\":\n\t\t\tensureProviderModels(p, []string{\"deepseek-v4-pro\"}, \"deepseek-v4-pro\")\n\t\t}\n\t}\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault string) {\n\tif p == nil {\n\t\treturn\n\t}\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional required models.\n\tif len(p.Models) > 0 {\n\t\treturn\n\t}\n\tmodels := mergeModelLists(required, p.ModelList())\n\tif len(models) == 0 {\n\t\treturn\n\t}\n\tp.Model = models[0]\n\tif len(models) > 1 {\n\t\tp.Models = models\n\t\tp.Default = firstKnownModel(p.Default, models, fallbackDefault)\n\t\treturn\n\t}\n\tp.Models = nil\n\tp.Default = \"\"\n}\n\nfunc legacyOfficialProviderModel(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\":\n\t\treturn \"deepseek-v4-flash\"\n\tcase \"deepseek-pro\":\n\t\treturn \"deepseek-v4-pro\"\n\tcase \"mimo-api\", \"mimo-pro\":\n\t\treturn \"mimo-v2.5-pro\"\n\tcase \"mimo-flash\":\n\t\treturn \"mimo-v2.5\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc normalizeDesktopOfficialProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) == 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tnext := make([]string, 0, len(c.Desktop.ProviderAccess))\n\tincludeMimoFlash := false\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif strings.TrimSpace(name) == \"mimo-flash\" {\n\t\t\tincludeMimoFlash = true\n\t\t}\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tseen[name] = true\n\t\tnext = append(next, name)\n\t}\n\tc.Desktop.ProviderAccess = next\n\tif seen[\"deepseek\"] {\n\t\tensureDeepSeekOfficialProvider(c)\n\t}\n\tif seen[\"mimo-api\"] {\n\t\tensureMimoAPIProvider(c)\n\t}\n\tif seen[\"mimo-token-plan\"] {\n\t\tensureMimoTokenPlanProvider(c, includeMimoFlash)\n\t}\n\tretargetDesktopOfficialRefs(c, seen)\n}\n\n// NormalizeLegacyDesktopProviderAccess seeds the desktop provider-access list\n// for configs written before Settings tracked explicit provider access. Callers\n// should only use this when they know the TOML did not declare provider_access;\n// an explicit empty list means the user removed all access entries.\nfunc NormalizeLegacyDesktopProviderAccess(c *Config) {\n\tif c == nil || len(c.Desktop.ProviderAccess) > 0 {\n\t\treturn\n\t}\n\tseen := desktopProviderAccessMap(nil)\n\tvar access []string\n\tadd := func(name string) {\n\t\tname = desktopProviderAccessNameForConfig(c, name)\n\t\tif name == \"\" || seen[name] {\n\t\t\treturn\n\t\t}\n\t\tseen[name] = true\n\t\taccess = append(access, name)\n\t}\n\taddRef := func(ref string) {\n\t\tif entry, ok := c.ResolveModel(ref); ok {\n\t\t\tif !entry.Configured() {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tadd(entry.Name)\n\t\t}\n\t}\n\taddRef(c.DefaultModel)\n\taddRef(c.Agent.PlannerModel)\n\taddRef(c.Agent.SubagentModel)\n\taddRef(c.Agent.AutoPlanClassifier)\n\tfor _, ref := range c.Agent.SubagentModels {\n\t\taddRef(ref)\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Configured() && len(p.ModelList()) > 0 {\n\t\t\tadd(p.Name)\n\t\t}\n\t}\n\tif len(access) == 0 {\n\t\treturn\n\t}\n\tc.Desktop.ProviderAccess = access\n\tnormalizeDesktopOfficialProviderAccess(c)\n}\n\nfunc canonicalDesktopOfficialProviderName(name string) string {\n\tswitch strings.TrimSpace(name) {\n\tcase \"deepseek-flash\", \"deepseek-pro\":\n\t\treturn \"deepseek\"\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn \"mimo-api\"\n\tcase \"mimo-pro\", \"mimo-flash\":\n\t\treturn \"mimo-token-plan\"\n\tdefault:\n\t\treturn strings.TrimSpace(name)\n\t}\n}\n\nfunc desktopProviderAccessNameForConfig(c *Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn \"\"\n\t}\n\tcanonical := canonicalDesktopOfficialProviderName(name)\n\tif canonical == name {\n\t\treturn name\n\t}\n\tif c == nil {\n\t\treturn canonical\n\t}\n\tif p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) {\n\t\treturn name\n\t}\n\treturn canonical\n}\n\nfunc providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\tswitch canonical {\n\tcase \"deepseek\":\n\t\treturn officialProviderKind(p) == \"deepseek\"\n\tcase \"mimo-api\":\n\t\treturn isOfficialMimoAPIProvider(p)\n\tcase \"mimo-token-plan\":\n\t\treturn isOfficialMimoTokenPlanProvider(p)\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// CanonicalDesktopOfficialProviderName returns the Settings Center provider ID\n// for built-in official provider aliases.\nfunc CanonicalDesktopOfficialProviderName(name string) string {\n\treturn canonicalDesktopOfficialProviderName(name)\n}\n\nfunc desktopProviderAccessMap(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc ensureDeepSeekOfficialProvider(c *Config) {\n\tif p, ok := c.Provider(\"deepseek\"); ok {\n\t\tif officialProviderKind(p) == \"deepseek\" {\n\t\t\tbackfillOfficialContextWindow(p, 1_000_000)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"deepseek\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\tDefault: \"deepseek-v4-flash\",\n\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\tContextWindow: 1_000_000,\n\t\tPrices: deepSeekV4PricesForConfig(c),\n\t}\n\tif old, ok := c.Provider(\"deepseek-flash\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Prices = deepSeekV4PricesForConfig(c)\n\t\tentry.Models = mergeModelLists([]string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"deepseek-v4-flash\")\n\t}\n\tbackfillOfficialContextWindow(&entry, 1_000_000)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc ensureMimoAPIProvider(c *Config) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"}\n\tvisionModels := []string{\"mimo-v2.5\", \"mimo-v2-omni\"}\n\tif p, ok := c.Provider(\"mimo-api\"); ok {\n\t\tif isOfficialMimoAPIProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tc.Providers = append(c.Providers, ProviderEntry{\n\t\tName: \"mimo-api\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t})\n}\n\nfunc ensureMimoTokenPlanProvider(c *Config, includeMimoFlash bool) {\n\tmodels := []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"}\n\tvisionModels := []string{\"mimo-v2.5\"}\n\tif p, ok := c.Provider(\"mimo-token-plan\"); ok {\n\t\tif isOfficialMimoTokenPlanProvider(p) {\n\t\t\tbackfillOfficialContextWindow(p, 1_048_576)\n\t\t\tmergeCuratedModelsIntoProvider(p, models, \"mimo-v2.5-pro\")\n\t\t\tmergeVisionModelsIntoProvider(p, visionModels)\n\t\t\tclearMixedMimoTokenPlanPrice(p)\n\t\t\tbackfillMimoDomesticPrices(p)\n\t\t}\n\t\treturn\n\t}\n\tentry := ProviderEntry{\n\t\tName: \"mimo-token-plan\",\n\t\tKind: \"openai\",\n\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\tModels: models,\n\t\tVisionModels: visionModels,\n\t\tDefault: \"mimo-v2.5-pro\",\n\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\tContextWindow: 1_048_576,\n\t\tPrices: mimoDomesticPrices(models),\n\t\tNoProxy: true,\n\t}\n\tif old, ok := c.Provider(\"mimo-pro\"); ok {\n\t\tentry = officialProviderFromLegacy(entry, old)\n\t\tentry.Models = mergeModelLists(models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, \"mimo-v2.5-pro\")\n\t}\n\tif old, ok := c.Provider(\"mimo-flash\"); includeMimoFlash && ok {\n\t\tif !providerHasAnyModel(entry) {\n\t\t\tentry = officialProviderFromLegacy(entry, old)\n\t\t}\n\t\tentry.Models = mergeModelLists(entry.Models, old.ModelList())\n\t\tentry.Default = firstKnownModel(entry.Default, entry.Models, entry.Default)\n\t}\n\tclearMixedMimoTokenPlanPrice(&entry)\n\tbackfillMimoDomesticPrices(&entry)\n\tbackfillOfficialContextWindow(&entry, 1_048_576)\n\tc.Providers = append(c.Providers, entry)\n}\n\nfunc isOfficialMimoAPIProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"api.xiaomimimo.com\"\n}\n\nfunc isOfficialMimoTokenPlanProvider(e *ProviderEntry) bool {\n\treturn isOpenAIProviderKind(e) && officialMimoHost(e.BaseURL) == \"token-plan-cn.xiaomimimo.com\"\n}\n\nfunc isOpenAIProviderKind(e *ProviderEntry) bool {\n\treturn e != nil && strings.EqualFold(strings.TrimSpace(e.Kind), \"openai\")\n}\n\nfunc mergeCuratedModelsIntoProvider(e *ProviderEntry, models []string, fallback string) {\n\t// If the user has explicitly curated a model list (via Settings), respect\n\t// that choice and do not merge additional curated models.\n\tif len(e.Models) > 0 {\n\t\treturn\n\t}\n\tcurrentDefault := e.Default\n\tif strings.TrimSpace(currentDefault) == \"\" {\n\t\tcurrentDefault = e.Model\n\t}\n\te.Models = mergeModelLists(models, e.ModelList())\n\te.Default = firstKnownModel(currentDefault, e.Models, fallback)\n}\n\nfunc mergeVisionModelsIntoProvider(e *ProviderEntry, models []string) {\n\tif e == nil {\n\t\treturn\n\t}\n\tenabled := map[string]bool{}\n\tfor _, model := range e.ChatModelList() {\n\t\tenabled[model] = true\n\t}\n\tmerged := e.VisionModels\n\tif merged == nil {\n\t\tmerged = models\n\t}\n\tout := make([]string, 0, len(merged))\n\tfor _, model := range merged {\n\t\tif enabled[model] && IsLikelyChatModel(model) {\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\te.VisionModels = out\n}\n\nfunc clearMixedMimoTokenPlanPrice(e *ProviderEntry) {\n\tif e != nil && e.HasModel(\"mimo-v2.5-pro\") && e.HasModel(\"mimo-v2.5\") {\n\t\te.Price = nil\n\t}\n}\n\nfunc backfillOfficialContextWindow(e *ProviderEntry, fallback int) {\n\tif e != nil && e.ContextWindow <= 0 {\n\t\te.ContextWindow = fallback\n\t}\n}\n\nfunc officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) ProviderEntry {\n\tentry.Kind = old.Kind\n\tentry.BaseURL = old.BaseURL\n\tentry.ModelsURL = old.ModelsURL\n\tentry.APIKeyEnv = old.APIKeyEnv\n\tentry.BalanceURL = old.BalanceURL\n\tentry.ContextWindow = old.ContextWindow\n\tentry.Price = old.Price\n\tentry.Thinking = old.Thinking\n\tentry.Effort = old.Effort\n\tentry.ReasoningProtocol = old.ReasoningProtocol\n\tentry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)\n\tentry.DefaultEffort = old.DefaultEffort\n\tentry.NoProxy = old.NoProxy\n\treturn entry\n}\n\nfunc mergeModelLists(primary, extra []string) []string {\n\tseen := map[string]bool{}\n\tout := make([]string, 0, len(primary)+len(extra))\n\tfor _, list := range [][]string{primary, extra} {\n\t\tfor _, model := range list {\n\t\t\tmodel = strings.TrimSpace(model)\n\t\t\tif model == \"\" || seen[model] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseen[model] = true\n\t\t\tout = append(out, model)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc firstKnownModel(current string, models []string, fallback string) string {\n\tcurrent = strings.TrimSpace(current)\n\tfor _, model := range models {\n\t\tif model == current {\n\t\t\treturn current\n\t\t}\n\t}\n\tfor _, model := range models {\n\t\tif model == fallback {\n\t\t\treturn fallback\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\nfunc retargetDesktopOfficialRefs(c *Config, access map[string]bool) {\n\tc.DefaultModel = retargetDesktopOfficialRef(c.DefaultModel, access)\n\tc.Agent.PlannerModel = retargetDesktopOfficialRef(c.Agent.PlannerModel, access)\n\tc.Agent.SubagentModel = retargetDesktopOfficialRef(c.Agent.SubagentModel, access)\n\tc.Agent.AutoPlanClassifier = retargetDesktopOfficialRef(c.Agent.AutoPlanClassifier, access)\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tc.Agent.SubagentModels[skill] = retargetDesktopOfficialRef(ref, access)\n\t}\n}\n\nfunc retargetDesktopOfficialRef(ref string, access map[string]bool) string {\n\tref = strings.TrimSpace(ref)\n\tif ref == \"\" {\n\t\treturn \"\"\n\t}\n\tprovider, model, hasModel := strings.Cut(ref, \"/\")\n\tswitch provider {\n\tcase \"deepseek-flash\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-flash\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"deepseek-pro\":\n\t\tif !access[\"deepseek\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"deepseek-v4-pro\"\n\t\t}\n\t\treturn \"deepseek/\" + model\n\tcase \"mimo-pro\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tcase \"mimo\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\tif !access[\"mimo-api\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5-pro\"\n\t\t}\n\t\treturn \"mimo-api/\" + model\n\tcase \"mimo-flash\":\n\t\tif !access[\"mimo-token-plan\"] {\n\t\t\treturn ref\n\t\t}\n\t\tif !hasModel || strings.TrimSpace(model) == \"\" {\n\t\t\tmodel = \"mimo-v2.5\"\n\t\t}\n\t\treturn \"mimo-token-plan/\" + model\n\tdefault:\n\t\treturn ref\n\t}\n}\n\nfunc userConfigPath() string {\n\tdir := userConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"config.toml\")\n}\n\nfunc userConfigDir() string {\n\treturn reasonixHomeDir()\n}\n\nfunc reasonixHomeDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tif runtime.GOOS != \"windows\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\treturn filepath.Join(home, \".reasonix\")\n\t\t}\n\t\treturn \"\"\n\t}\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc userConfigLoadPath() string {\n\tprimary := userConfigPath()\n\tif primary == \"\" {\n\t\treturn legacyUserConfigPath()\n\t}\n\tif _, err := os.Stat(primary); err == nil {\n\t\treturn primary\n\t}\n\tif legacy := legacyUserConfigPath(); legacy != \"\" {\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tif legacy == \"\" || samePath(legacy, primary) {\n\t\t\tcontinue\n\t\t}\n\t\tif _, err := os.Stat(legacy); err == nil {\n\t\t\treturn legacy\n\t\t}\n\t}\n\treturn primary\n}\n\nfunc legacyUserConfigPath() string {\n\tdir := legacyOSSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"config.toml\")\n\tif primary := userConfigPath(); primary != \"\" && samePath(path, primary) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userConfigCandidatePaths() []string {\n\tvar paths []string\n\tif p := userConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tif p := legacyUserConfigPath(); p != \"\" {\n\t\tpaths = append(paths, p)\n\t}\n\tpaths = append(paths, legacyXDGConfigPaths()...)\n\treturn paths\n}\n\nfunc legacyXDGConfigPaths() []string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn nil\n\t}\n\tseen := map[string]bool{}\n\tvar paths []string\n\tadd := func(path string) {\n\t\tif path == \"\" {\n\t\t\treturn\n\t\t}\n\t\tpath = filepath.Clean(path)\n\t\tif seen[path] {\n\t\t\treturn\n\t\t}\n\t\tseen[path] = true\n\t\tpaths = append(paths, path)\n\t}\n\tif dir := cleanEnvDir(\"XDG_CONFIG_HOME\"); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"reasonix\", \"config.toml\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tadd(filepath.Join(home, \".config\", \"reasonix\", \"config.toml\"))\n\t}\n\treturn paths\n}\n\nfunc userSupportDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_STATE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\treturn reasonixHomeDir()\n}\n\nfunc legacyOSSupportDir() string {\n\tdir := osUserConfigDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tpath := filepath.Join(dir, \"reasonix\")\n\tif current := reasonixHomeDir(); current != \"\" && samePath(path, current) {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\nfunc userCacheDir() string {\n\tif dir := cleanEnvDir(\"REASONIX_CACHE_HOME\"); dir != \"\" {\n\t\treturn dir\n\t}\n\tdir, err := os.UserCacheDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"reasonix\")\n}\n\nfunc osUserConfigDir() string {\n\tdir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc cleanEnvDir(name string) string {\n\tdir := strings.TrimSpace(os.Getenv(name))\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\tdir = ExpandVars(dir)\n\tif dir == \"~\" {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = home\n\t\t}\n\t} else if strings.HasPrefix(dir, \"~/\") || strings.HasPrefix(dir, `~\\`) {\n\t\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\t\tdir = filepath.Join(home, dir[2:])\n\t\t}\n\t}\n\tif !filepath.IsAbs(dir) {\n\t\tif abs, err := filepath.Abs(dir); err == nil {\n\t\t\tdir = abs\n\t\t}\n\t}\n\treturn filepath.Clean(dir)\n}\n\nfunc samePath(a, b string) bool {\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taa, aerr := filepath.Abs(a)\n\tbb, berr := filepath.Abs(b)\n\tif aerr == nil {\n\t\ta = aa\n\t}\n\tif berr == nil {\n\t\tb = bb\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// userConfigDisplayPath is userConfigPath collapsed to a ~-relative form for\n// comments rendered into the user's own config.toml, so Windows users see the\n// real location instead of a hardcoded ~/.reasonix path.\nfunc userConfigDisplayPath() string {\n\tp := userConfigPath()\n\tif p == \"\" {\n\t\treturn \"/reasonix/config.toml\"\n\t}\n\tif home, err := os.UserHomeDir(); err == nil && home != \"\" {\n\t\tif rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, \"..\") {\n\t\t\treturn \"~/\" + filepath.ToSlash(rel)\n\t\t}\n\t}\n\treturn p\n}\n\n// UserConfigPath is the user-global config.toml. It lives under Reasonix home:\n// REASONIX_HOME/config.toml, then ~/.reasonix/config.toml on Unix-like systems,\n// or %AppData%/reasonix/config.toml on Windows. \"\" when the user config dir\n// can't be resolved.\nfunc UserConfigPath() string { return userConfigPath() }\n\n// LegacyUserConfigPath is the old OS app-support config.toml path when it\n// differs from UserConfigPath. It is read as a compatibility fallback when the\n// primary user config does not exist.\nfunc LegacyUserConfigPath() string { return legacyUserConfigPath() }\n\n// LegacyUserConfigPaths returns every known legacy user config path that differs\n// from the current v1.8.1 Reasonix-home config path.\nfunc LegacyUserConfigPaths() []string {\n\tprimary := userConfigPath()\n\tvar out []string\n\tadd := func(path string) {\n\t\tif path == \"\" || samePath(path, primary) {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range out {\n\t\t\tif samePath(existing, path) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tout = append(out, path)\n\t}\n\tadd(legacyUserConfigPath())\n\tfor _, path := range legacyXDGConfigPaths() {\n\t\tadd(path)\n\t}\n\treturn out\n}\n\n// ReasonixHomeDir is the current Reasonix home directory. It honors\n// REASONIX_HOME, then uses ~/.reasonix on macOS/Linux or %APPDATA%/reasonix on\n// Windows.\nfunc ReasonixHomeDir() string { return reasonixHomeDir() }\n\n// UserCredentialsPath is the reasonix-owned global secrets file under Reasonix\n// home. It holds KEY=value lines loaded into the environment by loadDotEnv. The\n// setup wizard writes API keys here, deliberately NOT named .env: keys never\n// land in a project's own .env (which can't be selectively gitignored), never\n// get committed, and resolve from any working directory. \"\" when Reasonix home\n// can't be resolved.\nfunc UserCredentialsPath() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"credentials\")\n}\n\n// ArchiveDir is where compacted conversation history is archived for\n// traceability (one timestamped .jsonl per compaction). Empty if the user state\n// directory cannot be resolved, in which case archiving is skipped.\nfunc ArchiveDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"archive\")\n}\n\n// SessionDir is where chat sessions are persisted (one .jsonl per session).\n// Used by `reasonix --continue` / `--resume` to find the recent ones. Empty\n// if the user state dir can't be resolved — sessions then aren't saved.\nfunc SessionDir() string {\n\tdir := userSupportDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(dir, \"sessions\")\n}\n\n// ProjectSessionDir is the per-workspace session directory the desktop sidebar\n// lists: /projects//sessions. Empty when either the state root\n// or workspaceRoot doesn't resolve.\nfunc ProjectSessionDir(workspaceRoot string) string {\n\tbase := MemoryUserDir()\n\troot := strings.TrimSpace(workspaceRoot)\n\tif base == \"\" || root == \"\" {\n\t\treturn \"\"\n\t}\n\tif abs, err := filepath.Abs(root); err == nil {\n\t\troot = abs\n\t}\n\treturn filepath.Join(base, \"projects\", WorkspaceSlug(root), \"sessions\")\n}\n\n// WorkspaceSlug flattens an absolute workspace path into the directory name\n// used under /projects.\nfunc WorkspaceSlug(absPath string) string {\n\treturn strings.NewReplacer(string(os.PathSeparator), \"-\", \"/\", \"-\", \"\\\\\", \"-\", \":\", \"-\").Replace(absPath)\n}\n\n// CacheDir is the per-user cache root for derived/regenerable artefacts: MCP\n// handshake snapshots, plugin startup-latency telemetry. Empty when the OS dir is\n// unavailable — callers must tolerate that (caching is best-effort).\nfunc CacheDir() string {\n\tdir := userCacheDir()\n\tif dir == \"\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\n// MemoryUserDir returns the reasonix user state root (…/reasonix), under which\n// the user-global REASONIX.md and the per-project auto-memory store live. Empty\n// when the user state dir can't be resolved, which disables user-scoped memory.\nfunc MemoryUserDir() string {\n\treturn userSupportDir()\n}\n\n// ConventionDirs are the parent directories scanned for agent assets (skills,\n// commands), in canonical-first order. .reasonix is ours; .agents / .agent /\n// .claude let users drop in assets authored for other agent tools without moving\n// files. Shared so skills (internal/skill) and commands (CommandDirs) discover\n// the same set. Note: hooks are NOT scanned across these — a .claude/settings.json\n// uses a different hook schema that can't be parsed as ours, so hooks stay in\n// .reasonix/settings.json (see internal/hook).\nvar ConventionDirs = []string{\".reasonix\", \".agents\", \".agent\", \".claude\"}\n\n// conventionSubdirsAsc joins sub under each ConventionDir of base, in ascending\n// priority (reverse of ConventionDirs) so the canonical .reasonix ends up the\n// highest-priority entry — command.Load lets a later directory win on a clash.\nfunc conventionSubdirsAsc(base, sub string) []string {\n\tout := make([]string, 0, len(ConventionDirs))\n\tfor i := len(ConventionDirs) - 1; i >= 0; i-- {\n\t\tout = append(out, filepath.Join(base, ConventionDirs[i], sub))\n\t}\n\treturn out\n}\n\n// CommandDirs returns the directories scanned for custom slash commands, lowest\n// priority first, so a later (more specific) directory overrides an earlier one\n// on a name clash. Order: home-dir convention dirs (~/.claude/commands …\n// ~/.reasonix/commands), the Reasonix home commands dir, the legacy OS\n// app-support dir if different, then the project's\n// convention dirs (.claude/commands … .reasonix/commands). Scanning the .claude /\n// .agents / .agent dirs lets commands authored for other agent tools (same .md +\n// frontmatter format) work here unchanged.\nfunc CommandDirs() []string {\n\treturn CommandDirsForRoot(\".\")\n}\n\n// CommandDirsForRoot is like CommandDirs but resolves the project convention\n// dirs under root instead of the current working directory. Global dirs are\n// unchanged — they are always user-scoped.\nfunc CommandDirsForRoot(root string) []string {\n\troot = resolveRoot(root)\n\tvar dirs []string\n\tadd := func(dir string) {\n\t\tif dir == \"\" {\n\t\t\treturn\n\t\t}\n\t\tfor _, existing := range dirs {\n\t\t\tif samePath(existing, dir) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdirs = append(dirs, dir)\n\t}\n\tif dir := legacyOSSupportDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, legacy := range legacyXDGConfigPaths() {\n\t\tadd(filepath.Join(filepath.Dir(legacy), \"commands\"))\n\t}\n\tif home, err := os.UserHomeDir(); err == nil {\n\t\tfor _, dir := range conventionSubdirsAsc(home, \"commands\") {\n\t\t\tadd(dir)\n\t\t}\n\t}\n\tif dir := userConfigDir(); dir != \"\" {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tif dir := userSupportDir(); dir != \"\" && !samePath(dir, userConfigDir()) {\n\t\tadd(filepath.Join(dir, \"commands\"))\n\t}\n\tfor _, dir := range conventionSubdirsAsc(root, \"commands\") {\n\t\tadd(dir)\n\t}\n\treturn dirs\n}\n\n// SourcePath returns the highest-priority config file that exists, or \"\" if none.\nfunc SourcePath() string {\n\treturn SourcePathForRoot(\".\")\n}\n\n// SourcePathForRoot returns the highest-priority config file that exists under\n// root, or \"\" if none. Equivalent to SourcePath() when root is \".\".\nfunc SourcePathForRoot(root string) string {\n\troot = resolveRoot(root)\n\tprojectTOML := \"reasonix.toml\"\n\tif root != \".\" {\n\t\tprojectTOML = filepath.Join(root, \"reasonix.toml\")\n\t}\n\tif _, err := os.Stat(projectTOML); err == nil {\n\t\treturn projectTOML\n\t}\n\tif uc := userConfigLoadPath(); uc != \"\" {\n\t\tif _, err := os.Stat(uc); err == nil {\n\t\t\treturn uc\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// WriteFile writes the configuration to path as annotated TOML.\nfunc (c *Config) WriteFile(path string) error {\n\treturn os.WriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), 0o644)\n}\n\n// Provider returns the named provider entry.\nfunc (c *Config) Provider(name string) (*ProviderEntry, bool) {\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].Name == name {\n\t\t\treturn &c.Providers[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModel resolves a model reference to a provider entry whose Model is the\n// selected model string (a copy, so the config's lists stay intact). It accepts:\n// - \"provider/model\" — that exact model under that provider;\n// - a provider name — the provider's default model;\n// - a bare model name — the (first) provider that lists it.\n//\n// The returned entry is ready to build a provider from (NewProvider reads .Model),\n// so a single \"vendor with many models\" entry yields one instance per model\n// without duplicating base_url/api_key_env. Single-`model` entries still resolve\n// by provider name, keeping older configs working unchanged.\nfunc (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {\n\tif ref == \"\" {\n\t\treturn nil, false\n\t}\n\tif access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {\n\t\tref = retargetDesktopOfficialRef(ref, access)\n\t}\n\t// \"provider/model\"\n\tif prov, model, ok := strings.Cut(ref, \"/\"); ok {\n\t\tif e, found := c.Provider(prov); found && e.HasModel(model) {\n\t\t\tcp := *e\n\t\t\tcp.Model = model\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\t// a provider name → its default model\n\tif e, found := c.Provider(ref); found {\n\t\tcp := *e\n\t\tcp.Model = e.DefaultModel()\n\t\tcp.applyModelPrice()\n\t\treturn &cp, true\n\t}\n\t// a bare model name → the provider that lists it\n\tfor i := range c.Providers {\n\t\tif c.Providers[i].HasModel(ref) {\n\t\t\tcp := c.Providers[i]\n\t\t\tcp.Model = ref\n\t\t\tcp.applyModelPrice()\n\t\t\treturn &cp, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ResolveModelWithFallback resolves a model reference to the canonical\n// \"provider/model\" form used by the desktop runtime. If ref is stale or empty,\n// it tries the user's configured default_model before falling back to the first\n// configured provider — so preference isn't overwritten by iteration order.\nfunc (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {\n\tref = strings.TrimSpace(ref)\n\tif ref != \"\" {\n\t\tif e, found := c.ResolveModel(ref); found {\n\t\t\treturn e.Name + \"/\" + e.Model, false, true\n\t\t}\n\t}\n\t// Before falling back to the first configured provider (which may not be the\n\t// user's preferred choice), try the configured default_model. Skip when ref\n\t// already WAS the DefaultModel (it already failed above, so retrying won't\n\t// help) or when the default provider has no API key configured.\n\tif ref != c.DefaultModel && c.DefaultModel != \"\" {\n\t\tif e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {\n\t\t\treturn e.Name + \"/\" + e.Model, true, true\n\t\t}\n\t}\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\t// Skip providers with no models or no API key: falling back onto a keyless\n\t\t// provider just boots the tab onto something that fails on first use. Mirrors\n\t\t// the Configured() gate the provider-removal/selection paths already apply.\n\t\tif len(p.ModelList()) == 0 || !p.Configured() {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel(), true, true\n\t}\n\treturn \"\", false, false\n}\n\n// APIKey resolves the entry's API key from its api_key_env.\nfunc (e *ProviderEntry) APIKey() string {\n\tif e.APIKeyEnv == \"\" {\n\t\treturn \"\"\n\t}\n\treturn os.Getenv(e.APIKeyEnv)\n}\n\n// RequiresAPIKey reports whether this provider should be hidden/validated when\n// its configured api_key_env is empty. A blank api_key_env means the provider is\n// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy\n// api_key_env in config even though they accept unauthenticated requests, so\n// loopback/private endpoints are also allowed to run without a resolved key.\nfunc (e *ProviderEntry) RequiresAPIKey() bool {\n\tif e == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(e.APIKeyEnv) == \"\" {\n\t\treturn providerBaseURLRequiresAPIKey(e.BaseURL)\n\t}\n\treturn !providerBaseURLAllowsMissingAPIKey(e.BaseURL)\n}\n\nfunc providerBaseURLRequiresAPIKey(raw string) bool {\n\tswitch officialProviderHost(raw) {\n\tcase \"api.deepseek.com\", \"api.xiaomimimo.com\", \"token-plan-cn.xiaomimimo.com\", \"api.minimaxi.com\", \"api.openai.com\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc providerBaseURLAllowsMissingAPIKey(raw string) bool {\n\tu, err := url.Parse(strings.TrimSpace(raw))\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.Trim(strings.ToLower(u.Hostname()), \"[]\")\n\tif host == \"localhost\" || strings.HasSuffix(host, \".localhost\") {\n\t\treturn true\n\t}\n\taddr, err := netip.ParseAddr(host)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()\n}\n\n// Configured reports whether the provider is selectable. Providers that do not\n// require an API key are configured by definition; providers that name an env var\n// require that variable to resolve unless their endpoint is local/private.\nfunc (e *ProviderEntry) Configured() bool {\n\treturn e != nil && (!e.RequiresAPIKey() || e.APIKey() != \"\")\n}\n\n// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.\nfunc (c *Config) ResolveSystemPrompt() (string, error) {\n\treturn c.ResolveSystemPromptForRoot(\".\")\n}\n\n// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative\n// system_prompt_file against root. Desktop tabs pass their workspace root here so\n// prompt files are project-scoped even when the process cwd is elsewhere.\nfunc (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {\n\tif c.Agent.SystemPromptFile != \"\" {\n\t\tpath := c.Agent.SystemPromptFile\n\t\tif !filepath.IsAbs(path) {\n\t\t\tpath = filepath.Join(resolveRoot(root), path)\n\t\t}\n\t\tb, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"system_prompt_file: %w\", err)\n\t\t}\n\t\treturn strings.TrimSpace(string(b)), nil\n\t}\n\tif strings.TrimSpace(c.Agent.SystemPrompt) == \"\" {\n\t\treturn DefaultSystemPrompt, nil\n\t}\n\treturn c.Agent.SystemPrompt, nil\n}\n\n// Validate checks that the selected model's provider is usable.\nfunc (c *Config) Validate(model string) error {\n\te, ok := c.ResolveModel(model)\n\tif !ok {\n\t\treturn fmt.Errorf(\"unknown model %q (configured: %s)\", model, c.providerNames())\n\t}\n\tif e.Kind == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: kind is required\", model)\n\t}\n\tif e.BaseURL == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: base_url is required\", model)\n\t}\n\tif e.RequiresAPIKey() && e.APIKey() == \"\" {\n\t\treturn fmt.Errorf(\"provider %q: missing env %s\", model, e.APIKeyEnv)\n\t}\n\treturn nil\n}\n\nfunc (c *Config) providerNames() string {\n\tnames := make([]string, len(c.Providers))\n\tfor i, p := range c.Providers {\n\t\tnames[i] = p.Name\n\t}\n\treturn strings.Join(names, \", \")\n}\n"}}, "internal/provider/openai/openai_test.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785745654.390632, "n": 25892, "h": "9a892e15c39aef66", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\n// TestNewGLMSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `glm` flag when the base URL points at open.bigmodel.cn\n// so auto-detection works.\nfunc TestNewGLMSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://open.bigmodel.cn/api/paas/v4\",\n\t\t\"https://open.bigmodel.cn\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"glm\", BaseURL: baseURL, Model: \"glm-4\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.glm {\n\t\t\tt.Errorf(\"glm flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\",\"properties\":{}}`) && !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n\nfunc TestBuildRequestOmitsResponseOnlyToolCallIndex(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tToolCalls: []provider.ToolCall{{\n\t\t\t\tID: \"call_1\",\n\t\t\t\tName: \"bash\",\n\t\t\t\tArguments: `{\"cmd\":\"ls\"}`,\n\t\t\t}},\n\t\t}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) {\n\t\tt.Fatalf(\"request body missing tool call: %s\", s)\n\t}\n\tif strings.Contains(s, `\"index\"`) {\n\t\tt.Fatalf(\"request body contains response-only tool_call index: %s\", s)\n\t}\n}\n\nfunc TestBuildRequestOmitsEmptyToolDescriptionAndParameters(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\"}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tvar wire struct {\n\t\tTools []struct {\n\t\t\tFunction map[string]json.RawMessage `json:\"function\"`\n\t\t} `json:\"tools\"`\n\t}\n\tif err := json.Unmarshal(body, &wire); err != nil {\n\t\tt.Fatalf(\"unmarshal request: %v\\n%s\", err, body)\n\t}\n\tif len(wire.Tools) != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1: %s\", len(wire.Tools), body)\n\t}\n\tfn := wire.Tools[0].Function\n\tif string(fn[\"name\"]) != `\"noargs\"` {\n\t\tt.Fatalf(\"function name = %s, want noargs\", fn[\"name\"])\n\t}\n\tif _, ok := fn[\"description\"]; ok {\n\t\tt.Fatalf(\"empty description should be omitted: %s\", body)\n\t}\n\tif _, ok := fn[\"parameters\"]; ok {\n\t\tt.Fatalf(\"nil parameters should be omitted: %s\", body)\n\t}\n}\n"}, "4fe39d3092146290b1c60b2a77f869909f77f74e": {"t": 1785750389.9268265, "n": 25892, "h": "9a892e15c39aef66", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\n// TestNewGLMSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `glm` flag when the base URL points at open.bigmodel.cn\n// so auto-detection works.\nfunc TestNewGLMSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://open.bigmodel.cn/api/paas/v4\",\n\t\t\"https://open.bigmodel.cn\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"glm\", BaseURL: baseURL, Model: \"glm-4\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.glm {\n\t\t\tt.Errorf(\"glm flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\",\"properties\":{}}`) && !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n\nfunc TestBuildRequestOmitsResponseOnlyToolCallIndex(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tToolCalls: []provider.ToolCall{{\n\t\t\t\tID: \"call_1\",\n\t\t\t\tName: \"bash\",\n\t\t\t\tArguments: `{\"cmd\":\"ls\"}`,\n\t\t\t}},\n\t\t}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) {\n\t\tt.Fatalf(\"request body missing tool call: %s\", s)\n\t}\n\tif strings.Contains(s, `\"index\"`) {\n\t\tt.Fatalf(\"request body contains response-only tool_call index: %s\", s)\n\t}\n}\n\nfunc TestBuildRequestOmitsEmptyToolDescriptionAndParameters(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\"}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tvar wire struct {\n\t\tTools []struct {\n\t\t\tFunction map[string]json.RawMessage `json:\"function\"`\n\t\t} `json:\"tools\"`\n\t}\n\tif err := json.Unmarshal(body, &wire); err != nil {\n\t\tt.Fatalf(\"unmarshal request: %v\\n%s\", err, body)\n\t}\n\tif len(wire.Tools) != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1: %s\", len(wire.Tools), body)\n\t}\n\tfn := wire.Tools[0].Function\n\tif string(fn[\"name\"]) != `\"noargs\"` {\n\t\tt.Fatalf(\"function name = %s, want noargs\", fn[\"name\"])\n\t}\n\tif _, ok := fn[\"description\"]; ok {\n\t\tt.Fatalf(\"empty description should be omitted: %s\", body)\n\t}\n\tif _, ok := fn[\"parameters\"]; ok {\n\t\tt.Fatalf(\"nil parameters should be omitted: %s\", body)\n\t}\n}\n"}, "6de3bcbab39955e93c99aa58e0ca91c5d66fa1a3": {"t": 1785750391.1732383, "n": 25237, "h": "56782b9e75c936f8", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n\nfunc TestBuildRequestOmitsResponseOnlyToolCallIndex(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tToolCalls: []provider.ToolCall{{\n\t\t\t\tID: \"call_1\",\n\t\t\t\tName: \"bash\",\n\t\t\t\tArguments: `{\"cmd\":\"ls\"}`,\n\t\t\t}},\n\t\t}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) {\n\t\tt.Fatalf(\"request body missing tool call: %s\", s)\n\t}\n\tif strings.Contains(s, `\"index\"`) {\n\t\tt.Fatalf(\"request body contains response-only tool_call index: %s\", s)\n\t}\n}\n\nfunc TestBuildRequestOmitsEmptyToolDescriptionAndParameters(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\"}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tvar wire struct {\n\t\tTools []struct {\n\t\t\tFunction map[string]json.RawMessage `json:\"function\"`\n\t\t} `json:\"tools\"`\n\t}\n\tif err := json.Unmarshal(body, &wire); err != nil {\n\t\tt.Fatalf(\"unmarshal request: %v\\n%s\", err, body)\n\t}\n\tif len(wire.Tools) != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1: %s\", len(wire.Tools), body)\n\t}\n\tfn := wire.Tools[0].Function\n\tif string(fn[\"name\"]) != `\"noargs\"` {\n\t\tt.Fatalf(\"function name = %s, want noargs\", fn[\"name\"])\n\t}\n\tif _, ok := fn[\"description\"]; ok {\n\t\tt.Fatalf(\"empty description should be omitted: %s\", body)\n\t}\n\tif _, ok := fn[\"parameters\"]; ok {\n\t\tt.Fatalf(\"nil parameters should be omitted: %s\", body)\n\t}\n}\n"}, "42734604235b85d3939ac9d8e20f9ed0a312fda9": {"t": 1785750392.4107137, "n": 25193, "h": "88dc29d3302accd6", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\n// TestNewGLMSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `glm` flag when the base URL points at open.bigmodel.cn\n// so auto-detection works.\nfunc TestNewGLMSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://open.bigmodel.cn/api/paas/v4\",\n\t\t\"https://open.bigmodel.cn\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"glm\", BaseURL: baseURL, Model: \"glm-4\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.glm {\n\t\t\tt.Errorf(\"glm flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\",\"properties\":{}}`) && !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n\nfunc TestBuildRequestOmitsEmptyToolDescriptionAndParameters(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\"}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tvar wire struct {\n\t\tTools []struct {\n\t\t\tFunction map[string]json.RawMessage `json:\"function\"`\n\t\t} `json:\"tools\"`\n\t}\n\tif err := json.Unmarshal(body, &wire); err != nil {\n\t\tt.Fatalf(\"unmarshal request: %v\\n%s\", err, body)\n\t}\n\tif len(wire.Tools) != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1: %s\", len(wire.Tools), body)\n\t}\n\tfn := wire.Tools[0].Function\n\tif string(fn[\"name\"]) != `\"noargs\"` {\n\t\tt.Fatalf(\"function name = %s, want noargs\", fn[\"name\"])\n\t}\n\tif _, ok := fn[\"description\"]; ok {\n\t\tt.Fatalf(\"empty description should be omitted: %s\", body)\n\t}\n\tif _, ok := fn[\"parameters\"]; ok {\n\t\tt.Fatalf(\"nil parameters should be omitted: %s\", body)\n\t}\n}\n"}, "559b71d772565decc46bf805685b08a2d4d1d476": {"t": 1785750394.115133, "n": 24538, "h": "1404a2557c10578d", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n\nfunc TestBuildRequestOmitsEmptyToolDescriptionAndParameters(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\"}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tvar wire struct {\n\t\tTools []struct {\n\t\t\tFunction map[string]json.RawMessage `json:\"function\"`\n\t\t} `json:\"tools\"`\n\t}\n\tif err := json.Unmarshal(body, &wire); err != nil {\n\t\tt.Fatalf(\"unmarshal request: %v\\n%s\", err, body)\n\t}\n\tif len(wire.Tools) != 1 {\n\t\tt.Fatalf(\"tools = %d, want 1: %s\", len(wire.Tools), body)\n\t}\n\tfn := wire.Tools[0].Function\n\tif string(fn[\"name\"]) != `\"noargs\"` {\n\t\tt.Fatalf(\"function name = %s, want noargs\", fn[\"name\"])\n\t}\n\tif _, ok := fn[\"description\"]; ok {\n\t\tt.Fatalf(\"empty description should be omitted: %s\", body)\n\t}\n\tif _, ok := fn[\"parameters\"]; ok {\n\t\tt.Fatalf(\"nil parameters should be omitted: %s\", body)\n\t}\n}\n"}, "00a3d29d660f2c13876713b328d62b0d8922e864": {"t": 1785750395.3453965, "n": 23565, "h": "e37ab3568945f124", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n"}, "f99684a7a218248d0671814c4af2f7c490124e68": {"t": 1785750396.5652134, "n": 24604, "h": "cdb268ca9c9d4076", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\n// An old session (pre-adde2d3e) saved tool results without a name field. The\n// call still carries its name, so buildRequest must backfill the result name —\n// otherwise the wire omits it and DeepSeek 400s \"missing field name\" (#4773).\nfunc TestBuildRequestBackfillsToolResultNameForOldSession(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"c1\", Content: \"14\"},\n\t\t},\n\t})\n\ttool := req.Messages[len(req.Messages)-1]\n\tif tool.Role != \"tool\" {\n\t\tt.Fatalf(\"last message role = %q, want tool\", tool.Role)\n\t}\n\tb, err := json.Marshal(tool)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !strings.Contains(string(b), `\"name\":\"bash\"`) {\n\t\tt.Errorf(\"tool result must carry the backfilled name on the wire: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n"}, "e2d2177e074eb26b46723b14a12855587d78e86a": {"t": 1785750397.7901635, "n": 24220, "h": "4d360f1db4d1c8f9", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\n// TestNewGLMSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `glm` flag when the base URL points at open.bigmodel.cn\n// so auto-detection works.\nfunc TestNewGLMSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://open.bigmodel.cn/api/paas/v4\",\n\t\t\"https://open.bigmodel.cn\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"glm\", BaseURL: baseURL, Model: \"glm-4\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.glm {\n\t\t\tt.Errorf(\"glm flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\",\"properties\":{}}`) && !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n"}, "2254ca06c8aeda5ad6c1c5faa02264d4ade3ddaa": {"t": 1785750398.9924195, "n": 24146, "h": "f27b281e59733433", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\n// TestNewGLMSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `glm` flag when the base URL points at open.bigmodel.cn\n// so auto-detection works.\nfunc TestNewGLMSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://open.bigmodel.cn/api/paas/v4\",\n\t\t\"https://open.bigmodel.cn\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"glm\", BaseURL: baseURL, Model: \"glm-4\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.glm {\n\t\t\tt.Errorf(\"glm flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n"}, "278984fade01052b76608278338f2774fc68957a": {"t": 1785750400.2614412, "n": 23565, "h": "e37ab3568945f124", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\tif s, ok := m.Content.(string); ok {\n\t\t\t\ttoolContents = append(toolContents, s)\n\t\t\t}\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n"}, "0263ee9b3aedd87c38a8f10269811fd4035bc8bd": {"t": 1785750401.4943788, "n": 23548, "h": "475551d2969c9045", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\", deepseek: true}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"a no-tool-calls assistant turn must not carry reasoning_content: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\n// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n// dropped on a cache-miss replay, so it must be round-tripped — but only on the\n// turn that carries tool calls, and only for the DeepSeek protocol.\nfunc TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {\n\tmsgs := []provider.Message{\n\t\t{Role: provider.RoleUser, Content: \"count the go files\"},\n\t\t{\n\t\t\tRole: provider.RoleAssistant,\n\t\t\tReasoningContent: \"CHAIN-OF-THOUGHT\",\n\t\t\tToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"bash\", Arguments: `{\"command\":\"ls\"}`}},\n\t\t},\n\t\t{Role: provider.RoleTool, Content: \"14\", ToolCallID: \"c1\", Name: \"bash\"},\n\t}\n\tdeepseek, _ := json.Marshal((&client{model: \"deepseek-v4\", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif !strings.Contains(string(deepseek), \"reasoning_content\") || !strings.Contains(string(deepseek), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"DeepSeek tool_calls turn must round-trip reasoning_content: %s\", deepseek)\n\t}\n\n\tother, _ := json.Marshal((&client{model: \"mimo-v2\"}).buildRequest(provider.Request{Messages: msgs}).Messages)\n\tif strings.Contains(string(other), \"CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"non-DeepSeek backends must not re-upload reasoning_content: %s\", other)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) && m.Content != nil {\n\t\t\ttoolContents = append(toolContents, *m.Content)\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n"}, "8b2028e82427dc4925bc61c793c639116a44e8ee": {"t": 1785750403.0423694, "n": 22317, "h": "501a46af2b958f2d", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is\n// the only knob (no reasoning_effort), and the empty-effort / auto case still\n// emits an explicit \"adaptive\" because that's what the M3 model default means\n// (M3 has no implicit \"no thinking\" mode at the wire level).\nfunc TestBuildRequestMiniMaxThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t}{\n\t\t{name: \"auto-defaults-to-adaptive\", effort: \"\", wantThinking: \"adaptive\"},\n\t\t{name: \"adaptive\", effort: \"adaptive\", wantThinking: \"adaptive\"},\n\t\t{name: \"disabled\", effort: \"disabled\", wantThinking: \"disabled\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"MiniMax-M3\", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != \"\" {\n\t\t\t\tt.Fatalf(\"MiniMax must not send reasoning_effort, got %q\", req.ReasoningEffort)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNewMiniMaxEffortValidation locks in the boot-time validation for the\n// MiniMax path. The config effort layer remaps legacy level names, so by the\n// time effort reaches this factory it must be one of: \"\", \"adaptive\",\n// \"disabled\". Anything else is a config bug, surfaced now (not at request\n// time) for an actionable error.\nfunc TestNewMiniMaxEffortValidation(t *testing.T) {\n\tbase := provider.Config{Name: \"m3\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\", APIKey: \"k\"}\n\t// happy path: auto (empty effort) and both explicit values are accepted\n\tfor _, ok := range []string{\"\", \"adaptive\", \"disabled\"} {\n\t\tif _, err := New(withEffort(base, ok)); err != nil {\n\t\t\tt.Errorf(\"effort=%q should be accepted: %v\", ok, err)\n\t\t}\n\t}\n\t// unhappy: anything else is rejected up front\n\tfor _, bad := range []string{\"high\", \"low\", \"max\", \"turbo\"} {\n\t\tif _, err := New(withEffort(base, bad)); err == nil {\n\t\t\tt.Errorf(\"effort=%q should be rejected\", bad)\n\t\t}\n\t}\n}\n\n// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory\n// must set the `minimax` flag when the base URL points at api.minimaxi.com\n// (with or without the /v1 suffix) so buildRequest picks the right wire shape.\nfunc TestNewMiniMaxSetsFlag(t *testing.T) {\n\tfor _, baseURL := range []string{\n\t\t\"https://api.minimaxi.com/v1\",\n\t\t\"https://api.minimaxi.com\",\n\t} {\n\t\tp, err := New(provider.Config{Name: \"m3\", BaseURL: baseURL, Model: \"MiniMax-M3\", APIKey: \"k\"})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"New(%q): %v\", baseURL, err)\n\t\t}\n\t\tc := p.(*client)\n\t\tif !c.minimax {\n\t\t\tt.Errorf(\"minimax flag not set for baseURL=%q\", baseURL)\n\t\t}\n\t}\n}\n\nfunc withEffort(c provider.Config, effort string) provider.Config {\n\textra := c.Extra\n\tif extra == nil {\n\t\textra = map[string]any{}\n\t} else {\n\t\tcp := make(map[string]any, len(extra)+1)\n\t\tfor k, v := range extra {\n\t\t\tcp[k] = v\n\t\t}\n\t\textra = cp\n\t}\n\textra[\"effort\"] = effort\n\tc.Extra = extra\n\treturn c\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) && m.Content != nil {\n\t\t\ttoolContents = append(toolContents, *m.Content)\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n"}, "8feb6b3f17567aebe2192d73e83d684ba0707b71": {"t": 1785750404.2102869, "n": 19298, "h": "92cbbd35ee54f67f", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// DeepSeek rejects a message missing the `content` field, so every message must\n// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,\n// and accepted by DeepSeek — verified against a live multi-tool session); other\n// roles serialize a string. The field must never be absent.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:null and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `null` {\n\t\tt.Errorf(\"assistant content = %s, want null\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) && m.Content != nil {\n\t\t\ttoolContents = append(toolContents, *m.Content)\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n\nfunc TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {\n\tc := &client{name: \"x\", model: \"m\", baseURL: \"https://api.example.com/v1\"}\n\treq := provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleAssistant, Content: \"\", ToolCalls: []provider.ToolCall{{ID: \"c1\", Name: \"ls\", Arguments: `{}`}}},\n\t\t\t{Role: provider.RoleTool, Content: \"\", ToolCallID: \"c1\", Name: \"ls\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"all done\"},\n\t\t},\n\t\tTools: []provider.ToolSchema{{Name: \"noargs\", Parameters: provider.CanonicalizeSchema(nil)}},\n\t}\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif !json.Valid(body) {\n\t\tt.Fatalf(\"invalid JSON body: %s\", body)\n\t}\n\ts := string(body)\n\tif !strings.Contains(s, `\"tool_calls\"`) || !strings.Contains(s, `\"content\":null`) {\n\t\tt.Errorf(\"assistant tool_calls turn should carry null content: %s\", s)\n\t}\n\tif !strings.Contains(s, `{\"role\":\"tool\",\"content\":\"\"`) {\n\t\tt.Errorf(\"tool message should keep empty-string content, not null: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"content\":\"all done\"`) {\n\t\tt.Errorf(\"text assistant turn should keep its string content: %s\", s)\n\t}\n\tif !strings.Contains(s, `\"parameters\":{\"type\":\"object\"}`) {\n\t\tt.Errorf(\"no-param tool should serialize a valid empty-object schema: %s\", s)\n\t}\n}\n"}, "b5436493fc93ffd1502a854572eb1188e1e6b216": {"t": 1785750405.3745277, "n": 17873, "h": "6cf698f7d3b30386", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the\n// server returns 503 twice, then a valid SSE stream. The provider must back off,\n// fire the retry-notify callback for each attempt, and ultimately stream the answer.\nfunc TestStreamRetriesThenSucceeds(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tif reqs <= 2 {\n\t\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":\"overloaded\"}`))\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"hi there\\\"}}]}\\n\\ndata: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\tvar attempts []int\n\tctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {\n\t\tattempts = append(attempts, i.Attempt)\n\t\tif i.Max != provider.MaxRetries {\n\t\t\tt.Errorf(\"RetryInfo.Max = %d, want %d\", i.Max, provider.MaxRetries)\n\t\t}\n\t})\n\n\tch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream after retries: %v\", err)\n\t}\n\tvar got strings.Builder\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"unexpected stream error: %v\", chunk.Err)\n\t\t}\n\t\tif chunk.Type == provider.ChunkText {\n\t\t\tgot.WriteString(chunk.Text)\n\t\t}\n\t}\n\tif got.String() != \"hi there\" {\n\t\tt.Errorf(\"streamed text = %q, want %q\", got.String(), \"hi there\")\n\t}\n\tif reqs != 3 {\n\t\tt.Errorf(\"server saw %d requests, want 3 (2 failures + 1 success)\", reqs)\n\t}\n\tif len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {\n\t\tt.Errorf(\"retry-notify attempts = %v, want [1 2]\", attempts)\n\t}\n}\n\n// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed\n// *provider.APIError carrying the status, so the display layer can explain it.\nfunc TestStreamInsufficientBalance(t *testing.T) {\n\tvar reqs int\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treqs++\n\t\tw.WriteHeader(http.StatusPaymentRequired)\n\t\t_, _ = w.Write([]byte(`{\"error\":\"Insufficient Balance\"}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, _ := New(provider.Config{Name: \"deepseek\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\t_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}}})\n\tvar apiErr *provider.APIError\n\tif !errors.As(err, &apiErr) || apiErr.Status != 402 {\n\t\tt.Fatalf(\"want *provider.APIError{Status:402}, got %T: %v\", err, err)\n\t}\n\tif reqs != 1 {\n\t\tt.Errorf(\"402 should not retry, server saw %d requests\", reqs)\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// an assistant turn that is pure tool_calls (no preamble text) has empty\n// content, and DeepSeek rejects a message missing the `content` field. Every\n// message — including that one — must serialize a content field.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:\"\" and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `\"\"` {\n\t\tt.Errorf(\"assistant content = %s, want \\\"\\\"\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\ttoolContents = append(toolContents, m.Content)\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n"}, "2c7f7e17a62582cdd35cca9d3cd8a4f89cda0819": {"t": 1785750406.9920511, "n": 16046, "h": "62fbdcacf1a82bfe", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.\nfunc TestIsRetryableStatus(t *testing.T) {\n\tretry := []int{408, 429, 500, 502, 503, 504, 599}\n\tnoRetry := []int{200, 400, 401, 403, 404, 422}\n\tfor _, s := range retry {\n\t\tif !isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should be retryable\", s)\n\t\t}\n\t}\n\tfor _, s := range noRetry {\n\t\tif isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should not be retryable\", s)\n\t\t}\n\t}\n}\n\n// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of\n// the retry path while letting network-level failures through.\nfunc TestIsTransientErr(t *testing.T) {\n\tif isTransientErr(nil) {\n\t\tt.Error(\"nil error should not be transient\")\n\t}\n\tif isTransientErr(context.Canceled) {\n\t\tt.Error(\"ctx canceled should not be transient\")\n\t}\n\tif isTransientErr(context.DeadlineExceeded) {\n\t\tt.Error(\"ctx deadline should not be transient\")\n\t}\n\tif !isTransientErr(errors.New(\"connection reset\")) {\n\t\tt.Error(\"generic network-ish error should be transient\")\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// an assistant turn that is pure tool_calls (no preamble text) has empty\n// content, and DeepSeek rejects a message missing the `content` field. Every\n// message — including that one — must serialize a content field.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:\"\" and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `\"\"` {\n\t\tt.Errorf(\"assistant content = %s, want \\\"\\\"\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"off\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New should migrate retired effort=off, not reject it: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"high\" {\n\t\tt.Fatalf(\"retired effort=off should fall back to high, got %q\", got)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\ttoolContents = append(toolContents, m.Content)\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n"}, "3bf6a6111a5b3ec16dc3178f0c8fcc7930206c00": {"t": 1785750408.2147987, "n": 15758, "h": "0a4d562a4bc7c29b", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.\nfunc TestIsRetryableStatus(t *testing.T) {\n\tretry := []int{408, 429, 500, 502, 503, 504, 599}\n\tnoRetry := []int{200, 400, 401, 403, 404, 422}\n\tfor _, s := range retry {\n\t\tif !isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should be retryable\", s)\n\t\t}\n\t}\n\tfor _, s := range noRetry {\n\t\tif isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should not be retryable\", s)\n\t\t}\n\t}\n}\n\n// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of\n// the retry path while letting network-level failures through.\nfunc TestIsTransientErr(t *testing.T) {\n\tif isTransientErr(nil) {\n\t\tt.Error(\"nil error should not be transient\")\n\t}\n\tif isTransientErr(context.Canceled) {\n\t\tt.Error(\"ctx canceled should not be transient\")\n\t}\n\tif isTransientErr(context.DeadlineExceeded) {\n\t\tt.Error(\"ctx deadline should not be transient\")\n\t}\n\tif !isTransientErr(errors.New(\"connection reset\")) {\n\t\tt.Error(\"generic network-ish error should be transient\")\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// an assistant turn that is pure tool_calls (no preamble text) has empty\n// content, and DeepSeek rejects a message missing the `content` field. Every\n// message — including that one — must serialize a content field.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:\"\" and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `\"\"` {\n\t\tt.Errorf(\"assistant content = %s, want \\\"\\\"\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestDeepSeekThinking(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname string\n\t\teffort string\n\t\twantThinking string\n\t\twantReasoning string\n\t}{\n\t\t{name: \"high\", effort: \"high\", wantThinking: \"enabled\", wantReasoning: \"high\"},\n\t\t{name: \"max\", effort: \"max\", wantThinking: \"enabled\", wantReasoning: \"max\"},\n\t\t{name: \"off\", effort: \"off\", wantThinking: \"disabled\", wantReasoning: \"\"},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq := (&client{model: \"deepseek-v4\", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})\n\t\t\tif req.Thinking == nil || req.Thinking.Type != tc.wantThinking {\n\t\t\t\tt.Fatalf(\"Thinking = %+v, want %q\", req.Thinking, tc.wantThinking)\n\t\t\t}\n\t\t\tif req.ReasoningEffort != tc.wantReasoning {\n\t\t\t\tt.Fatalf(\"ReasoningEffort = %q, want %q\", req.ReasoningEffort, tc.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {\n\treq := (&client{model: \"mimo-v2\", effort: \"high\"}).buildRequest(provider.Request{})\n\tif req.Thinking != nil {\n\t\tt.Fatalf(\"non-DeepSeek request must not include thinking, got %+v\", req.Thinking)\n\t}\n\tif req.ReasoningEffort != \"high\" {\n\t\tt.Fatalf(\"ReasoningEffort = %q, want high\", req.ReasoningEffort)\n\t}\n}\n\nfunc TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {\n\tp, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tc := p.(*client)\n\tif !c.deepseek || c.effort != \"high\" {\n\t\tt.Fatalf(\"deepseek=%v effort=%q, want true/high\", c.deepseek, c.effort)\n\t}\n\n\tp, err = New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com/v1\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"max\"}})\n\tif err != nil {\n\t\tt.Fatalf(\"New max: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"max\" {\n\t\tt.Fatalf(\"effort = %q, want max\", got)\n\t}\n\n\tif _, err := New(provider.Config{Name: \"deepseek\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4\", Extra: map[string]any{\"effort\": \"medium\"}}); err == nil {\n\t\tt.Fatal(\"New should reject invalid DeepSeek effort\")\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\ttoolContents = append(toolContents, m.Content)\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n"}, "be584c8c70361a77c4fb8a8b15b5893bd4cbf306": {"t": 1785750410.3302455, "n": 13643, "h": "0ddb2e8c413a70ca", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.\nfunc TestIsRetryableStatus(t *testing.T) {\n\tretry := []int{408, 429, 500, 502, 503, 504, 599}\n\tnoRetry := []int{200, 400, 401, 403, 404, 422}\n\tfor _, s := range retry {\n\t\tif !isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should be retryable\", s)\n\t\t}\n\t}\n\tfor _, s := range noRetry {\n\t\tif isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should not be retryable\", s)\n\t\t}\n\t}\n}\n\n// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of\n// the retry path while letting network-level failures through.\nfunc TestIsTransientErr(t *testing.T) {\n\tif isTransientErr(nil) {\n\t\tt.Error(\"nil error should not be transient\")\n\t}\n\tif isTransientErr(context.Canceled) {\n\t\tt.Error(\"ctx canceled should not be transient\")\n\t}\n\tif isTransientErr(context.DeadlineExceeded) {\n\t\tt.Error(\"ctx deadline should not be transient\")\n\t}\n\tif !isTransientErr(errors.New(\"connection reset\")) {\n\t\tt.Error(\"generic network-ish error should be transient\")\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// an assistant turn that is pure tool_calls (no preamble text) has empty\n// content, and DeepSeek rejects a message missing the `content` field. Every\n// message — including that one — must serialize a content field.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:\"\" and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `\"\"` {\n\t\tt.Errorf(\"assistant content = %s, want \\\"\\\"\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n\n// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose\n// calls carry no id (some OpenAI-compatible gateways omit it, sending only the\n// index) keeps every tool result through buildRequest. SanitizeToolPairing keys\n// on tool_call_id, so empty ids collapse and all but the last result is dropped.\nfunc TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"scan\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"a\"}`},\n\t\t\t\t{ID: \"\", Name: \"read_file\", Arguments: `{\"p\":\"b\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-A\"},\n\t\t\t{Role: provider.RoleTool, ToolCallID: \"\", Name: \"read_file\", Content: \"RESULT-B\"},\n\t\t},\n\t})\n\tvar toolContents []string\n\tfor _, m := range req.Messages {\n\t\tif m.Role == string(provider.RoleTool) {\n\t\t\ttoolContents = append(toolContents, m.Content)\n\t\t}\n\t}\n\tif len(toolContents) != 2 {\n\t\tt.Fatalf(\"want 2 tool results in request, got %d: %v\", len(toolContents), toolContents)\n\t}\n\tif toolContents[0] == toolContents[1] {\n\t\tt.Errorf(\"tool results collapsed to %q — a result was dropped from the model's context\", toolContents[0])\n\t}\n}\n\n// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool\n// calls by index with no id (vLLM / llama.cpp do this). Each completed call must\n// come back with a stable, distinct synthetic id so its result can pair back.\nfunc TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"a\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"p\\\":\\\"b\\\"}\"}}]}}]}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, `data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}`+\"\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"local\", BaseURL: srv.URL, Model: \"qwen\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tvar ids []string\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {\n\t\t\tids = append(ids, chunk.ToolCall.ID)\n\t\t}\n\t}\n\tif len(ids) != 2 {\n\t\tt.Fatalf(\"want 2 tool calls, got %d: %v\", len(ids), ids)\n\t}\n\tif ids[0] == \"\" || ids[1] == \"\" {\n\t\tt.Errorf(\"a tool call came back with an empty id: %v\", ids)\n\t}\n\tif ids[0] == ids[1] {\n\t\tt.Errorf(\"synthesized ids must be distinct, got %v\", ids)\n\t}\n}\n"}, "7f67c065bec5c79f46eee1f4f621beaf246dd82d": {"t": 1785750411.428993, "n": 10617, "h": "4e98a80b7846dded", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.\nfunc TestIsRetryableStatus(t *testing.T) {\n\tretry := []int{408, 429, 500, 502, 503, 504, 599}\n\tnoRetry := []int{200, 400, 401, 403, 404, 422}\n\tfor _, s := range retry {\n\t\tif !isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should be retryable\", s)\n\t\t}\n\t}\n\tfor _, s := range noRetry {\n\t\tif isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should not be retryable\", s)\n\t\t}\n\t}\n}\n\n// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of\n// the retry path while letting network-level failures through.\nfunc TestIsTransientErr(t *testing.T) {\n\tif isTransientErr(nil) {\n\t\tt.Error(\"nil error should not be transient\")\n\t}\n\tif isTransientErr(context.Canceled) {\n\t\tt.Error(\"ctx canceled should not be transient\")\n\t}\n\tif isTransientErr(context.DeadlineExceeded) {\n\t\tt.Error(\"ctx deadline should not be transient\")\n\t}\n\tif !isTransientErr(errors.New(\"connection reset\")) {\n\t\tt.Error(\"generic network-ish error should be transient\")\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// an assistant turn that is pure tool_calls (no preamble text) has empty\n// content, and DeepSeek rejects a message missing the `content` field. Every\n// message — including that one — must serialize a content field.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:\"\" and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `\"\"` {\n\t\tt.Errorf(\"assistant content = %s, want \\\"\\\"\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400\n// \"An assistant message with 'tool_calls' must be followed by tool messages\n// responding to each 'tool_call_id'\". A resumed/interrupted session can carry an\n// assistant tool_calls turn whose tool results never landed; the server here\n// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the\n// request must be repaired before it is sent.\nfunc TestStreamRepairsDanglingToolCalls(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar req struct {\n\t\t\tMessages []struct {\n\t\t\t\tRole string `json:\"role\"`\n\t\t\t\tToolCalls []struct {\n\t\t\t\t\tID string `json:\"id\"`\n\t\t\t\t} `json:\"tool_calls\"`\n\t\t\t\tToolCallID string `json:\"tool_call_id\"`\n\t\t\t} `json:\"messages\"`\n\t\t}\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = json.Unmarshal(body, &req)\n\t\tanswered := map[string]bool{}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role == \"tool\" {\n\t\t\t\tanswered[m.ToolCallID] = true\n\t\t\t}\n\t\t}\n\t\tfor _, m := range req.Messages {\n\t\t\tif m.Role != \"assistant\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, tc := range m.ToolCalls {\n\t\t\t\tif !answered[tc.ID] {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"done\\\"}}]}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: {\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"stop\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":5,\\\"completion_tokens\\\":1,\\\"total_tokens\\\":6}}\\n\\n\")\n\t\t_, _ = io.WriteString(w, \"data: [DONE]\\n\\n\")\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{Name: \"deepseek-flash\", BaseURL: srv.URL, Model: \"deepseek-v4\", APIKey: \"k\"})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t// An assistant tool_calls turn whose tool result never landed (an interrupted\n\t// turn), followed by a fresh user message — the exact shape that 400s.\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleUser, Content: \"never mind, what time is it?\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream sent a dangling tool_calls to the API: %v\", err)\n\t}\n\tvar streamErr error\n\tvar text strings.Builder\n\tfor chunk := range ch {\n\t\tswitch chunk.Type {\n\t\tcase provider.ChunkText:\n\t\t\ttext.WriteString(chunk.Text)\n\t\tcase provider.ChunkError:\n\t\t\tstreamErr = chunk.Err\n\t\t}\n\t}\n\tif streamErr != nil {\n\t\tt.Fatalf(\"stream errored: %v\", streamErr)\n\t}\n\tif text.String() != \"done\" {\n\t\tt.Fatalf(\"completion text = %q, want \\\"done\\\"\", text.String())\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n"}, "09a941cc937b1cf7dafc03021910ccf1b87844c8": {"t": 1785750414.5550003, "n": 7551, "h": "4536375266ae8cd4", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.\nfunc TestIsRetryableStatus(t *testing.T) {\n\tretry := []int{408, 429, 500, 502, 503, 504, 599}\n\tnoRetry := []int{200, 400, 401, 403, 404, 422}\n\tfor _, s := range retry {\n\t\tif !isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should be retryable\", s)\n\t\t}\n\t}\n\tfor _, s := range noRetry {\n\t\tif isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should not be retryable\", s)\n\t\t}\n\t}\n}\n\n// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of\n// the retry path while letting network-level failures through.\nfunc TestIsTransientErr(t *testing.T) {\n\tif isTransientErr(nil) {\n\t\tt.Error(\"nil error should not be transient\")\n\t}\n\tif isTransientErr(context.Canceled) {\n\t\tt.Error(\"ctx canceled should not be transient\")\n\t}\n\tif isTransientErr(context.DeadlineExceeded) {\n\t\tt.Error(\"ctx deadline should not be transient\")\n\t}\n\tif !isTransientErr(errors.New(\"connection reset\")) {\n\t\tt.Error(\"generic network-ish error should be transient\")\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// an assistant turn that is pure tool_calls (no preamble text) has empty\n// content, and DeepSeek rejects a message missing the `content` field. Every\n// message — including that one — must serialize a content field.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:\"\" and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `\"\"` {\n\t\tt.Errorf(\"assistant content = %s, want \\\"\\\"\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n\nfunc TestBuildRequestForwardsReasoningEffort(t *testing.T) {\n\tc := &client{model: \"mimo-v2\", effort: \"high\"}\n\tif got := c.buildRequest(provider.Request{}).ReasoningEffort; got != \"high\" {\n\t\tt.Errorf(\"ReasoningEffort = %q, want high\", got)\n\t}\n\n\tb, err := json.Marshal((&client{model: \"deepseek-v4\"}).buildRequest(provider.Request{}))\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_effort\") {\n\t\tt.Errorf(\"empty effort must be omitted from the payload: %s\", b)\n\t}\n}\n\nfunc TestNewReadsEffortFromConfig(t *testing.T) {\n\tp, err := New(provider.Config{\n\t\tName: \"mimo\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModel: \"mimo-v2\",\n\t\tExtra: map[string]any{\"effort\": \"medium\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\tif got := p.(*client).effort; got != \"medium\" {\n\t\tt.Errorf(\"effort = %q, want medium\", got)\n\t}\n}\n"}, "b5d07bf29b0cd8517720980cba86f705aed7d65d": {"t": 1785750415.995372, "n": 6682, "h": "8b550024540d24f5", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.\nfunc TestIsRetryableStatus(t *testing.T) {\n\tretry := []int{408, 429, 500, 502, 503, 504, 599}\n\tnoRetry := []int{200, 400, 401, 403, 404, 422}\n\tfor _, s := range retry {\n\t\tif !isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should be retryable\", s)\n\t\t}\n\t}\n\tfor _, s := range noRetry {\n\t\tif isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should not be retryable\", s)\n\t\t}\n\t}\n}\n\n// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of\n// the retry path while letting network-level failures through.\nfunc TestIsTransientErr(t *testing.T) {\n\tif isTransientErr(nil) {\n\t\tt.Error(\"nil error should not be transient\")\n\t}\n\tif isTransientErr(context.Canceled) {\n\t\tt.Error(\"ctx canceled should not be transient\")\n\t}\n\tif isTransientErr(context.DeadlineExceeded) {\n\t\tt.Error(\"ctx deadline should not be transient\")\n\t}\n\tif !isTransientErr(errors.New(\"connection reset\")) {\n\t\tt.Error(\"generic network-ish error should be transient\")\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// an assistant turn that is pure tool_calls (no preamble text) has empty\n// content, and DeepSeek rejects a message missing the `content` field. Every\n// message — including that one — must serialize a content field.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:\"\" and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `\"\"` {\n\t\tt.Errorf(\"assistant content = %s, want \\\"\\\"\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n\n// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant\n// turn's reasoning_content is a response-only signal and must never be echoed\n// back in the outgoing request. DeepSeek otherwise counts it as paid prompt\n// input (~500 tok/turn on a reasoner chain). The session keeps it for\n// display/archive; the wire request must not carry it.\nfunc TestBuildRequestDropsReasoningContent(t *testing.T) {\n\tc := &client{model: \"deepseek-reasoner\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"explain\"},\n\t\t\t{Role: provider.RoleAssistant, Content: \"the answer\", ReasoningContent: \"SECRET-CHAIN-OF-THOUGHT\"},\n\t\t\t{Role: provider.RoleUser, Content: \"thanks\"},\n\t\t},\n\t})\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\tif strings.Contains(string(b), \"reasoning_content\") {\n\t\tt.Errorf(\"outgoing request must not carry a reasoning_content field: %s\", b)\n\t}\n\tif strings.Contains(string(b), \"SECRET-CHAIN-OF-THOUGHT\") {\n\t\tt.Errorf(\"the assistant chain-of-thought leaked into the request: %s\", b)\n\t}\n\t// The visible answer must survive — we only drop reasoning, not content.\n\tif !strings.Contains(string(b), \"the answer\") {\n\t\tt.Errorf(\"assistant content was dropped along with reasoning: %s\", b)\n\t}\n}\n"}, "cdeb4de63c721269498590632a2e0f2ee778752b": {"t": 1785750417.0735579, "n": 5365, "h": "257482170a70826c", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.\nfunc TestIsRetryableStatus(t *testing.T) {\n\tretry := []int{408, 429, 500, 502, 503, 504, 599}\n\tnoRetry := []int{200, 400, 401, 403, 404, 422}\n\tfor _, s := range retry {\n\t\tif !isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should be retryable\", s)\n\t\t}\n\t}\n\tfor _, s := range noRetry {\n\t\tif isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should not be retryable\", s)\n\t\t}\n\t}\n}\n\n// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of\n// the retry path while letting network-level failures through.\nfunc TestIsTransientErr(t *testing.T) {\n\tif isTransientErr(nil) {\n\t\tt.Error(\"nil error should not be transient\")\n\t}\n\tif isTransientErr(context.Canceled) {\n\t\tt.Error(\"ctx canceled should not be transient\")\n\t}\n\tif isTransientErr(context.DeadlineExceeded) {\n\t\tt.Error(\"ctx deadline should not be transient\")\n\t}\n\tif !isTransientErr(errors.New(\"connection reset\")) {\n\t\tt.Error(\"generic network-ish error should be transient\")\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:\n// an assistant turn that is pure tool_calls (no preamble text) has empty\n// content, and DeepSeek rejects a message missing the `content` field. Every\n// message — including that one — must serialize a content field.\nfunc TestBuildRequestAlwaysSerializesContent(t *testing.T) {\n\tc := &client{model: \"deepseek-v4\"}\n\treq := c.buildRequest(provider.Request{\n\t\tMessages: []provider.Message{\n\t\t\t{Role: provider.RoleUser, Content: \"list the files\"},\n\t\t\t// Assistant turn with no text, only a tool call — the offending shape.\n\t\t\t{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{\n\t\t\t\t{ID: \"call_1\", Name: \"ls\", Arguments: `{\"path\":\".\"}`},\n\t\t\t}},\n\t\t\t{Role: provider.RoleTool, Content: \"main.go\", ToolCallID: \"call_1\", Name: \"ls\"},\n\t\t},\n\t})\n\n\tb, err := json.Marshal(req.Messages)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal: %v\", err)\n\t}\n\t// Decode generically so we can assert the key's presence (not just its value).\n\tvar raw []map[string]json.RawMessage\n\tif err := json.Unmarshal(b, &raw); err != nil {\n\t\tt.Fatalf(\"unmarshal: %v\", err)\n\t}\n\tfor i, m := range raw {\n\t\tif _, ok := m[\"content\"]; !ok {\n\t\t\tt.Errorf(\"messages[%d] is missing the content field: %s\", i, b)\n\t\t}\n\t}\n\t// The tool-call-only assistant message must carry content:\"\" and its tool_calls.\n\tif got := string(raw[1][\"content\"]); got != `\"\"` {\n\t\tt.Errorf(\"assistant content = %s, want \\\"\\\"\", got)\n\t}\n\tif _, ok := raw[1][\"tool_calls\"]; !ok {\n\t\tt.Errorf(\"assistant message lost its tool_calls: %s\", b)\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n"}, "7de6a2474fb913f1b94480e671e85276deb46270": {"t": 1785750418.2995932, "n": 3800, "h": "0f9889f4ac55c170", "c": "package openai\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/provider\"\n)\n\n// TestIsRetryableStatus covers the boundary: 408/429/5xx retry, other 4xx don't.\nfunc TestIsRetryableStatus(t *testing.T) {\n\tretry := []int{408, 429, 500, 502, 503, 504, 599}\n\tnoRetry := []int{200, 400, 401, 403, 404, 422}\n\tfor _, s := range retry {\n\t\tif !isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should be retryable\", s)\n\t\t}\n\t}\n\tfor _, s := range noRetry {\n\t\tif isRetryableStatus(s) {\n\t\t\tt.Errorf(\"status %d should not be retryable\", s)\n\t\t}\n\t}\n}\n\n// TestIsTransientErr keeps user-intent errors (ctx cancel / deadline) out of\n// the retry path while letting network-level failures through.\nfunc TestIsTransientErr(t *testing.T) {\n\tif isTransientErr(nil) {\n\t\tt.Error(\"nil error should not be transient\")\n\t}\n\tif isTransientErr(context.Canceled) {\n\t\tt.Error(\"ctx canceled should not be transient\")\n\t}\n\tif isTransientErr(context.DeadlineExceeded) {\n\t\tt.Error(\"ctx deadline should not be transient\")\n\t}\n\tif !isTransientErr(errors.New(\"connection reset\")) {\n\t\tt.Error(\"generic network-ish error should be transient\")\n\t}\n}\n\n// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError\n// (naming the provider and its key env var) rather than a raw status body.\nfunc TestStreamAuthError(t *testing.T) {\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"Authentication Fails, Your api key: ****ae54 is invalid\"}}`))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := New(provider.Config{\n\t\tName: \"deepseek\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4\",\n\t\tAPIKey: \"bad\",\n\t\tExtra: map[string]any{\"api_key_env\": \"DEEPSEEK_API_KEY\"},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"New: %v\", err)\n\t}\n\n\t_, err = p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tvar authErr *provider.AuthError\n\tif !errors.As(err, &authErr) {\n\t\tt.Fatalf(\"want *provider.AuthError, got %T: %v\", err, err)\n\t}\n\tif authErr.Provider != \"deepseek\" || authErr.KeyEnv != \"DEEPSEEK_API_KEY\" || authErr.Status != 401 {\n\t\tt.Errorf(\"AuthError fields wrong: %+v\", authErr)\n\t}\n\tif msg := authErr.Error(); !strings.Contains(msg, \"DEEPSEEK_API_KEY\") || strings.Contains(msg, \"ae54\") {\n\t\tt.Errorf(\"message should name the env var and not dump the raw body: %q\", msg)\n\t}\n}\n\n// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.\nfunc TestNormaliseUsageDeepSeekShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 200,\n\t\tTotalTokens: 1200,\n\t\tPromptCacheHitTokens: 900,\n\t\tPromptCacheMissTokens: 100,\n\t})\n\tif u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {\n\t\tt.Errorf(\"DeepSeek-shape cache fields lost: hit=%d miss=%d\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n}\n\n// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /\n// completion_tokens_details path used by OpenAI and MiMo. Miss is derived\n// from prompt - hit when only hit is provided.\nfunc TestNormaliseUsageMiMoShape(t *testing.T) {\n\tu := normaliseUsage(&wireUsage{\n\t\tPromptTokens: 1000,\n\t\tCompletionTokens: 500,\n\t\tTotalTokens: 1500,\n\t\tPromptTokensDetails: &struct {\n\t\t\tCachedTokens int `json:\"cached_tokens\"`\n\t\t}{CachedTokens: 600},\n\t\tCompletionTokensDetails: &struct {\n\t\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t\t}{ReasoningTokens: 180},\n\t})\n\tif u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {\n\t\tt.Errorf(\"nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)\", u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tif u.ReasoningTokens != 180 {\n\t\tt.Errorf(\"reasoning tokens lost: %d\", u.ReasoningTokens)\n\t}\n}\n"}}, "internal/config/effort_test.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785746067.765007, "n": 4170, "h": "aeb81fcd644a52de", "c": "package config\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestIsMiniMaxEntry(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tbaseURL string\n\t\twant bool\n\t}{\n\t\t{\"https://api.minimaxi.com/v1\", true},\n\t\t{\"https://api.minimaxi.com\", true},\n\t\t{\"https://api.minimaxi.com/anthropic\", true},\n\t\t// Subdomain variants of the canonical host.\n\t\t{\"https://eu.minimaxi.com/v1\", true},\n\t\t{\"https://us.minimaxi.com/v1\", true},\n\t\t// Bare apex (no api. prefix) is rejected: it would only match if\n\t\t// the user pointed their base_url at the apex domain, which is a\n\t\t// misconfiguration — not a path we want to silently accept.\n\t\t{\"https://minimaxi.com/v1\", false},\n\t\t{\"https://minimaxi.com\", false},\n\t\t// Other vendors must not match.\n\t\t{\"https://api.deepseek.com\", false},\n\t\t{\"https://api.xiaomimimo.com/v1\", false},\n\t\t{\"https://api.minimax.example.com\", false}, // wrong spelling — must not match\n\t\t{\"\", false},\n\t} {\n\t\te := &ProviderEntry{Kind: \"openai\", BaseURL: tc.baseURL}\n\t\tif got := isMiniMaxEntry(e); got != tc.want {\n\t\t\tt.Errorf(\"baseURL=%q: isMiniMaxEntry=%v, want %v\", tc.baseURL, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestEffortCapabilityMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\tcap := EffortCapabilityForEntry(e)\n\tif !cap.Supported {\n\t\tt.Fatalf(\"M3 entry should expose /effort, got %+v\", cap)\n\t}\n\twantLevels := []string{\"auto\", \"adaptive\", \"disabled\"}\n\tif len(cap.Levels) != len(wantLevels) {\n\t\tt.Fatalf(\"levels = %v, want %v\", cap.Levels, wantLevels)\n\t}\n\tfor i, l := range wantLevels {\n\t\tif cap.Levels[i] != l {\n\t\t\tt.Errorf(\"levels[%d] = %q, want %q\", i, cap.Levels[i], l)\n\t\t}\n\t}\n\tif cap.Default != \"adaptive\" {\n\t\tt.Errorf(\"default = %q, want adaptive (M3 ships with thinking on)\", cap.Default)\n\t}\n}\n\nfunc TestNormalizeEffortMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\tcases := []struct {\n\t\tin, want string\n\t}{\n\t\t{\"auto\", \"\"}, // auto == \"leave to provider default\" == empty\n\t\t{\"adaptive\", \"adaptive\"},\n\t\t{\"disabled\", \"disabled\"},\n\t\t{\"ADAPTIVE\", \"adaptive\"}, // case-insensitive\n\t\t// Stale values from other vendors still resolve to a valid M3 level\n\t\t// rather than failing the /effort command.\n\t\t{\"off\", \"disabled\"}, // retired DeepSeek \"no thinking\" → M3 actually supports \"disabled\"\n\t\t{\"low\", \"adaptive\"},\n\t\t{\"medium\", \"adaptive\"},\n\t\t{\"high\", \"adaptive\"},\n\t\t{\"xhigh\", \"disabled\"},\n\t\t{\"max\", \"disabled\"},\n\t}\n\tfor _, tc := range cases {\n\t\tgot, err := NormalizeEffort(e, tc.in)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) returned error: %v\", tc.in, err)\n\t\t\tcontinue\n\t\t}\n\t\tif got != tc.want {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) = %q, want %q\", tc.in, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestNormalizeEffortMiniMaxRejectsGarbage(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\t// \"turbo\" and similar unrecognised inputs reach the MiniMax switch and\n\t// are rejected with the MiniMax-specific usage hint. Empty input is\n\t// rejected earlier with the generic hint; both are valid user-facing\n\t// errors. \"off\" is *not* in this list — it's a retired level we now\n\t// migrate to \"adaptive\" (tested in TestNormalizeEffortMiniMax above).\n\tcases := map[string]string{\n\t\t\"turbo\": \"auto|adaptive|disabled\",\n\t\t\"\": \"auto|\",\n\t}\n\tfor in, wantHint := range cases {\n\t\t_, err := NormalizeEffort(e, in)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) should be rejected\", in)\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.Contains(err.Error(), wantHint) {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) error = %q, want it to mention %q\", in, err.Error(), wantHint)\n\t\t}\n\t}\n}\n\nfunc TestEffectiveEffortMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\t// unset → EffectiveEffort stays empty so the wire layer defaults to adaptive\n\tif got := EffectiveEffort(e); got != \"\" {\n\t\tt.Errorf(\"unset EffectiveEffort = %q, want \\\"\\\"\", got)\n\t}\n\t// explicit value is preserved verbatim\n\te.Effort = \"disabled\"\n\tif got := EffectiveEffort(e); got != \"disabled\" {\n\t\tt.Errorf(\"explicit EffectiveEffort = %q, want disabled\", got)\n\t}\n}\n"}, "8b2028e82427dc4925bc61c793c639116a44e8ee": {"t": 1785750388.4691467, "n": 4170, "h": "aeb81fcd644a52de", "c": "package config\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestIsMiniMaxEntry(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tbaseURL string\n\t\twant bool\n\t}{\n\t\t{\"https://api.minimaxi.com/v1\", true},\n\t\t{\"https://api.minimaxi.com\", true},\n\t\t{\"https://api.minimaxi.com/anthropic\", true},\n\t\t// Subdomain variants of the canonical host.\n\t\t{\"https://eu.minimaxi.com/v1\", true},\n\t\t{\"https://us.minimaxi.com/v1\", true},\n\t\t// Bare apex (no api. prefix) is rejected: it would only match if\n\t\t// the user pointed their base_url at the apex domain, which is a\n\t\t// misconfiguration — not a path we want to silently accept.\n\t\t{\"https://minimaxi.com/v1\", false},\n\t\t{\"https://minimaxi.com\", false},\n\t\t// Other vendors must not match.\n\t\t{\"https://api.deepseek.com\", false},\n\t\t{\"https://api.xiaomimimo.com/v1\", false},\n\t\t{\"https://api.minimax.example.com\", false}, // wrong spelling — must not match\n\t\t{\"\", false},\n\t} {\n\t\te := &ProviderEntry{Kind: \"openai\", BaseURL: tc.baseURL}\n\t\tif got := isMiniMaxEntry(e); got != tc.want {\n\t\t\tt.Errorf(\"baseURL=%q: isMiniMaxEntry=%v, want %v\", tc.baseURL, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestEffortCapabilityMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\tcap := EffortCapabilityForEntry(e)\n\tif !cap.Supported {\n\t\tt.Fatalf(\"M3 entry should expose /effort, got %+v\", cap)\n\t}\n\twantLevels := []string{\"auto\", \"adaptive\", \"disabled\"}\n\tif len(cap.Levels) != len(wantLevels) {\n\t\tt.Fatalf(\"levels = %v, want %v\", cap.Levels, wantLevels)\n\t}\n\tfor i, l := range wantLevels {\n\t\tif cap.Levels[i] != l {\n\t\t\tt.Errorf(\"levels[%d] = %q, want %q\", i, cap.Levels[i], l)\n\t\t}\n\t}\n\tif cap.Default != \"adaptive\" {\n\t\tt.Errorf(\"default = %q, want adaptive (M3 ships with thinking on)\", cap.Default)\n\t}\n}\n\nfunc TestNormalizeEffortMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\tcases := []struct {\n\t\tin, want string\n\t}{\n\t\t{\"auto\", \"\"}, // auto == \"leave to provider default\" == empty\n\t\t{\"adaptive\", \"adaptive\"},\n\t\t{\"disabled\", \"disabled\"},\n\t\t{\"ADAPTIVE\", \"adaptive\"}, // case-insensitive\n\t\t// Stale values from other vendors still resolve to a valid M3 level\n\t\t// rather than failing the /effort command.\n\t\t{\"off\", \"disabled\"}, // retired DeepSeek \"no thinking\" → M3 actually supports \"disabled\"\n\t\t{\"low\", \"adaptive\"},\n\t\t{\"medium\", \"adaptive\"},\n\t\t{\"high\", \"adaptive\"},\n\t\t{\"xhigh\", \"disabled\"},\n\t\t{\"max\", \"disabled\"},\n\t}\n\tfor _, tc := range cases {\n\t\tgot, err := NormalizeEffort(e, tc.in)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) returned error: %v\", tc.in, err)\n\t\t\tcontinue\n\t\t}\n\t\tif got != tc.want {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) = %q, want %q\", tc.in, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestNormalizeEffortMiniMaxRejectsGarbage(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\t// \"turbo\" and similar unrecognised inputs reach the MiniMax switch and\n\t// are rejected with the MiniMax-specific usage hint. Empty input is\n\t// rejected earlier with the generic hint; both are valid user-facing\n\t// errors. \"off\" is *not* in this list — it's a retired level we now\n\t// migrate to \"adaptive\" (tested in TestNormalizeEffortMiniMax above).\n\tcases := map[string]string{\n\t\t\"turbo\": \"auto|adaptive|disabled\",\n\t\t\"\": \"auto|\",\n\t}\n\tfor in, wantHint := range cases {\n\t\t_, err := NormalizeEffort(e, in)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) should be rejected\", in)\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.Contains(err.Error(), wantHint) {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) error = %q, want it to mention %q\", in, err.Error(), wantHint)\n\t\t}\n\t}\n}\n\nfunc TestEffectiveEffortMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\t// unset → EffectiveEffort stays empty so the wire layer defaults to adaptive\n\tif got := EffectiveEffort(e); got != \"\" {\n\t\tt.Errorf(\"unset EffectiveEffort = %q, want \\\"\\\"\", got)\n\t}\n\t// explicit value is preserved verbatim\n\te.Effort = \"disabled\"\n\tif got := EffectiveEffort(e); got != \"disabled\" {\n\t\tt.Errorf(\"explicit EffectiveEffort = %q, want disabled\", got)\n\t}\n}\n"}}, "internal/cli/cli_test.go": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785746096.55319, "n": 46369, "h": "7474bb8a8a2b5b91", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestLoadResumableSessionRejectsCleanupPending(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"pending.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := loadResumableSession(path); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"loadResumableSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n}\n\nfunc TestRunResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-run.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runAgent([]string{\"--resume\", path, \"continue task\"}); rc != 1 {\n\t\t\tt.Fatalf(\"run --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"run --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestServeResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-serve.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--resume\", path, \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"serve --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestServeRejectsUnknownAuthMode(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--auth\", \"tokne\", \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --auth tokne rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"auth mode must be none, token, or password\") {\n\t\tt.Fatalf(\"serve --auth tokne stderr = %q, want auth mode validation\", errOut)\n\t}\n}\n\nfunc TestServePasswordAuthRequiresPasswordMaterial(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--auth\", \"password\", \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --auth password without password rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"auth mode password requires --password or serve.password_hash\") {\n\t\tt.Fatalf(\"serve --auth password stderr = %q, want password material validation\", errOut)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tcwd := mustGetwd(t)\n\tif err := os.WriteFile(filepath.Join(cwd, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"cwd-project\"\ncommand = \"cwd-project-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmigrateLegacyConfigForCLI()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"early CLI legacy migration imported the cwd project plugin: %+v\", cfg.Plugins)\n\t}\n\n\tmigrateMCPConfigForCLIWorkspace()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"workspace-aware CLI migration did not import project plugin: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc hasPluginNamed(cfg *config.Config, name string) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tfor _, plugin := range cfg.Plugins {\n\t\tif plugin.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalIsRejected(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"--local is not supported\") {\n\t\tt.Fatalf(\"config auto-plan --local stderr = %q\", errOut)\n\t}\n\tif _, err := os.Stat(\"reasonix.toml\"); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"reasonix.toml should not be written, stat err=%v\", err)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"off\" {\n\t\tt.Fatalf(\"auto_plan = %q, want global off\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanIgnoresProjectConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tif err := userCfg.SetAutoPlan(\"off\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\tif err := os.WriteFile(\"reasonix.toml\", []byte(\"[agent]\\nauto_plan = \\\"on\\\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load config: %v\", err)\n\t}\n\tif cfg.Agent.AutoPlan != \"off\" {\n\t\tt.Fatalf(\"auto_plan = %q, want user-level off despite project on\", cfg.Agent.AutoPlan)\n\t}\n\n\tif err := userCfg.SetAutoPlan(\"on\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"rewrite user config: %v\", err)\n\t}\n\tif err := os.WriteFile(\"reasonix.toml\", []byte(\"[agent]\\nauto_plan = \\\"off\\\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"rewrite project config: %v\", err)\n\t}\n\tcfg, err = config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"reload config: %v\", err)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want user-level on despite project off\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only the configured default is keyed\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers,\n\t\tconfig.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t\tconfig.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t)\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\n\tselected := config.Default().Providers\n\n\tinput := \"ds-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reused)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reset)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), io.Discard)\n\tif len(env) != 1 {\n\t\tt.Errorf(\"env = %v, want 1 (DeepSeek reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. Without dedupe, different dotenv readers can disagree on which\n// assignment wins, leaving stale keys hard to diagnose.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro), preserving the order each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" {\n\t\tt.Errorf(\"display name = %q\", info[\"deepseek\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesDoesNotAddMissingMimo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] {\n\t\tt.Fatalf(\"wizard families = %v, want DeepSeek\", order)\n\t}\n\tif seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, should not inject MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\n// TestWithBuiltinFamiliesRestoresSiblingEntries covers the re-run scenario:\n// a user previously selected only deepseek-v4-flash (saved as deepseek-flash\n// with a single model). Re-running `reasonix setup` must still surface the\n// sibling deepseek-pro entry so the user can pick deepseek-v4-pro too,\n// rather than only showing the previously selected model.\nfunc TestWithBuiltinFamiliesRestoresSiblingEntries(t *testing.T) {\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Models: []string{\"deepseek-v4-flash\"}, APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tgot := withBuiltinFamilies(cfg)\n\n\t// deepseek-pro must be restored even though deepseek family already exists.\n\tvar found bool\n\tfor _, p := range got {\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"withBuiltinFamilies(%+v) = %v, want deepseek-pro sibling restored\", cfg, namesOf(got))\n\t}\n\n\t// The static model list for the deepseek family must include both SKUs.\n\t_, members, _ := groupByFamily(got)\n\tdeepseekIdxs := members[\"deepseek\"]\n\tmodels := familyStaticModels(got, deepseekIdxs)\n\twantModels := map[string]bool{\"deepseek-v4-flash\": true, \"deepseek-v4-pro\": true}\n\tfor _, m := range models {\n\t\tdelete(wantModels, m)\n\t}\n\tif len(wantModels) > 0 {\n\t\tt.Errorf(\"familyStaticModels = %v, missing %v\", models, wantModels)\n\t}\n}\n\nfunc namesOf(ps []config.ProviderEntry) []string {\n\tout := make([]string, len(ps))\n\tfor i, p := range ps {\n\t\tout[i] = p.Name\n\t}\n\treturn out\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "1f390dd0e86a2ca16058aee0cf6bcf180b0a0627": {"t": 1785750398.4994822, "n": 46369, "h": "7474bb8a8a2b5b91", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestLoadResumableSessionRejectsCleanupPending(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"pending.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := loadResumableSession(path); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"loadResumableSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n}\n\nfunc TestRunResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-run.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runAgent([]string{\"--resume\", path, \"continue task\"}); rc != 1 {\n\t\t\tt.Fatalf(\"run --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"run --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestServeResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-serve.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--resume\", path, \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"serve --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestServeRejectsUnknownAuthMode(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--auth\", \"tokne\", \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --auth tokne rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"auth mode must be none, token, or password\") {\n\t\tt.Fatalf(\"serve --auth tokne stderr = %q, want auth mode validation\", errOut)\n\t}\n}\n\nfunc TestServePasswordAuthRequiresPasswordMaterial(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--auth\", \"password\", \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --auth password without password rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"auth mode password requires --password or serve.password_hash\") {\n\t\tt.Fatalf(\"serve --auth password stderr = %q, want password material validation\", errOut)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tcwd := mustGetwd(t)\n\tif err := os.WriteFile(filepath.Join(cwd, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"cwd-project\"\ncommand = \"cwd-project-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmigrateLegacyConfigForCLI()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"early CLI legacy migration imported the cwd project plugin: %+v\", cfg.Plugins)\n\t}\n\n\tmigrateMCPConfigForCLIWorkspace()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"workspace-aware CLI migration did not import project plugin: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc hasPluginNamed(cfg *config.Config, name string) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tfor _, plugin := range cfg.Plugins {\n\t\tif plugin.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalIsRejected(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"--local is not supported\") {\n\t\tt.Fatalf(\"config auto-plan --local stderr = %q\", errOut)\n\t}\n\tif _, err := os.Stat(\"reasonix.toml\"); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"reasonix.toml should not be written, stat err=%v\", err)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"off\" {\n\t\tt.Fatalf(\"auto_plan = %q, want global off\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanIgnoresProjectConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tif err := userCfg.SetAutoPlan(\"off\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\tif err := os.WriteFile(\"reasonix.toml\", []byte(\"[agent]\\nauto_plan = \\\"on\\\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load config: %v\", err)\n\t}\n\tif cfg.Agent.AutoPlan != \"off\" {\n\t\tt.Fatalf(\"auto_plan = %q, want user-level off despite project on\", cfg.Agent.AutoPlan)\n\t}\n\n\tif err := userCfg.SetAutoPlan(\"on\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"rewrite user config: %v\", err)\n\t}\n\tif err := os.WriteFile(\"reasonix.toml\", []byte(\"[agent]\\nauto_plan = \\\"off\\\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"rewrite project config: %v\", err)\n\t}\n\tcfg, err = config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"reload config: %v\", err)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want user-level on despite project off\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only the configured default is keyed\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers,\n\t\tconfig.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t\tconfig.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t)\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\n\tselected := config.Default().Providers\n\n\tinput := \"ds-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reused)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reset)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), io.Discard)\n\tif len(env) != 1 {\n\t\tt.Errorf(\"env = %v, want 1 (DeepSeek reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. Without dedupe, different dotenv readers can disagree on which\n// assignment wins, leaving stale keys hard to diagnose.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro), preserving the order each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" {\n\t\tt.Errorf(\"display name = %q\", info[\"deepseek\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesDoesNotAddMissingMimo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] {\n\t\tt.Fatalf(\"wizard families = %v, want DeepSeek\", order)\n\t}\n\tif seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, should not inject MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\n// TestWithBuiltinFamiliesRestoresSiblingEntries covers the re-run scenario:\n// a user previously selected only deepseek-v4-flash (saved as deepseek-flash\n// with a single model). Re-running `reasonix setup` must still surface the\n// sibling deepseek-pro entry so the user can pick deepseek-v4-pro too,\n// rather than only showing the previously selected model.\nfunc TestWithBuiltinFamiliesRestoresSiblingEntries(t *testing.T) {\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Models: []string{\"deepseek-v4-flash\"}, APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tgot := withBuiltinFamilies(cfg)\n\n\t// deepseek-pro must be restored even though deepseek family already exists.\n\tvar found bool\n\tfor _, p := range got {\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"withBuiltinFamilies(%+v) = %v, want deepseek-pro sibling restored\", cfg, namesOf(got))\n\t}\n\n\t// The static model list for the deepseek family must include both SKUs.\n\t_, members, _ := groupByFamily(got)\n\tdeepseekIdxs := members[\"deepseek\"]\n\tmodels := familyStaticModels(got, deepseekIdxs)\n\twantModels := map[string]bool{\"deepseek-v4-flash\": true, \"deepseek-v4-pro\": true}\n\tfor _, m := range models {\n\t\tdelete(wantModels, m)\n\t}\n\tif len(wantModels) > 0 {\n\t\tt.Errorf(\"familyStaticModels = %v, missing %v\", models, wantModels)\n\t}\n}\n\nfunc namesOf(ps []config.ProviderEntry) []string {\n\tout := make([]string, len(ps))\n\tfor i, p := range ps {\n\t\tout[i] = p.Name\n\t}\n\treturn out\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "7e7c7eda097114c87b2247e036c0df8311bd7c62": {"t": 1785750399.8003144, "n": 45448, "h": "ff85b48ef7cb91f8", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestLoadResumableSessionRejectsCleanupPending(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"pending.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := loadResumableSession(path); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"loadResumableSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n}\n\nfunc TestRunResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-run.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runAgent([]string{\"--resume\", path, \"continue task\"}); rc != 1 {\n\t\t\tt.Fatalf(\"run --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"run --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestServeResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-serve.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--resume\", path, \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"serve --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tcwd := mustGetwd(t)\n\tif err := os.WriteFile(filepath.Join(cwd, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"cwd-project\"\ncommand = \"cwd-project-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmigrateLegacyConfigForCLI()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"early CLI legacy migration imported the cwd project plugin: %+v\", cfg.Plugins)\n\t}\n\n\tmigrateMCPConfigForCLIWorkspace()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"workspace-aware CLI migration did not import project plugin: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc hasPluginNamed(cfg *config.Config, name string) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tfor _, plugin := range cfg.Plugins {\n\t\tif plugin.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalIsRejected(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"--local is not supported\") {\n\t\tt.Fatalf(\"config auto-plan --local stderr = %q\", errOut)\n\t}\n\tif _, err := os.Stat(\"reasonix.toml\"); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"reasonix.toml should not be written, stat err=%v\", err)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"off\" {\n\t\tt.Fatalf(\"auto_plan = %q, want global off\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanIgnoresProjectConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tif err := userCfg.SetAutoPlan(\"off\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\tif err := os.WriteFile(\"reasonix.toml\", []byte(\"[agent]\\nauto_plan = \\\"on\\\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load config: %v\", err)\n\t}\n\tif cfg.Agent.AutoPlan != \"off\" {\n\t\tt.Fatalf(\"auto_plan = %q, want user-level off despite project on\", cfg.Agent.AutoPlan)\n\t}\n\n\tif err := userCfg.SetAutoPlan(\"on\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"rewrite user config: %v\", err)\n\t}\n\tif err := os.WriteFile(\"reasonix.toml\", []byte(\"[agent]\\nauto_plan = \\\"off\\\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"rewrite project config: %v\", err)\n\t}\n\tcfg, err = config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"reload config: %v\", err)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want user-level on despite project off\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only the configured default is keyed\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers,\n\t\tconfig.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t\tconfig.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t)\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\n\tselected := config.Default().Providers\n\n\tinput := \"ds-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reused)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reset)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), io.Discard)\n\tif len(env) != 1 {\n\t\tt.Errorf(\"env = %v, want 1 (DeepSeek reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. Without dedupe, different dotenv readers can disagree on which\n// assignment wins, leaving stale keys hard to diagnose.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro), preserving the order each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" {\n\t\tt.Errorf(\"display name = %q\", info[\"deepseek\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesDoesNotAddMissingMimo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] {\n\t\tt.Fatalf(\"wizard families = %v, want DeepSeek\", order)\n\t}\n\tif seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, should not inject MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\n// TestWithBuiltinFamiliesRestoresSiblingEntries covers the re-run scenario:\n// a user previously selected only deepseek-v4-flash (saved as deepseek-flash\n// with a single model). Re-running `reasonix setup` must still surface the\n// sibling deepseek-pro entry so the user can pick deepseek-v4-pro too,\n// rather than only showing the previously selected model.\nfunc TestWithBuiltinFamiliesRestoresSiblingEntries(t *testing.T) {\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Models: []string{\"deepseek-v4-flash\"}, APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tgot := withBuiltinFamilies(cfg)\n\n\t// deepseek-pro must be restored even though deepseek family already exists.\n\tvar found bool\n\tfor _, p := range got {\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"withBuiltinFamilies(%+v) = %v, want deepseek-pro sibling restored\", cfg, namesOf(got))\n\t}\n\n\t// The static model list for the deepseek family must include both SKUs.\n\t_, members, _ := groupByFamily(got)\n\tdeepseekIdxs := members[\"deepseek\"]\n\tmodels := familyStaticModels(got, deepseekIdxs)\n\twantModels := map[string]bool{\"deepseek-v4-flash\": true, \"deepseek-v4-pro\": true}\n\tfor _, m := range models {\n\t\tdelete(wantModels, m)\n\t}\n\tif len(wantModels) > 0 {\n\t\tt.Errorf(\"familyStaticModels = %v, missing %v\", models, wantModels)\n\t}\n}\n\nfunc namesOf(ps []config.ProviderEntry) []string {\n\tout := make([]string, len(ps))\n\tfor i, p := range ps {\n\t\tout[i] = p.Name\n\t}\n\treturn out\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "01d9b1730edf7be9f130a3296f8ecdc3ae3bc742": {"t": 1785750401.2445319, "n": 45456, "h": "8e6070d7f7bb2744", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestLoadResumableSessionRejectsCleanupPending(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"pending.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := loadResumableSession(path); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"loadResumableSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n}\n\nfunc TestRunResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-run.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runAgent([]string{\"--resume\", path, \"continue task\"}); rc != 1 {\n\t\t\tt.Fatalf(\"run --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"run --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestServeResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-serve.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--resume\", path, \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"serve --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tcwd := mustGetwd(t)\n\tif err := os.WriteFile(filepath.Join(cwd, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"cwd-project\"\ncommand = \"cwd-project-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmigrateLegacyConfigForCLI()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"early CLI legacy migration imported the cwd project plugin: %+v\", cfg.Plugins)\n\t}\n\n\tmigrateMCPConfigForCLIWorkspace()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"workspace-aware CLI migration did not import project plugin: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc hasPluginNamed(cfg *config.Config, name string) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tfor _, plugin := range cfg.Plugins {\n\t\tif plugin.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalIsRejected(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"--local is not supported\") {\n\t\tt.Fatalf(\"config auto-plan --local stderr = %q\", errOut)\n\t}\n\tif _, err := os.Stat(\"reasonix.toml\"); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"reasonix.toml should not be written, stat err=%v\", err)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"off\" {\n\t\tt.Fatalf(\"auto_plan = %q, want global off\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanIgnoresProjectConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tif err := userCfg.SetAutoPlan(\"off\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\tif err := os.WriteFile(\"reasonix.toml\", []byte(\"[agent]\\nauto_plan = \\\"on\\\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write project config: %v\", err)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load config: %v\", err)\n\t}\n\tif cfg.Agent.AutoPlan != \"off\" {\n\t\tt.Fatalf(\"auto_plan = %q, want user-level off despite project on\", cfg.Agent.AutoPlan)\n\t}\n\n\tif err := userCfg.SetAutoPlan(\"on\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"rewrite user config: %v\", err)\n\t}\n\tif err := os.WriteFile(\"reasonix.toml\", []byte(\"[agent]\\nauto_plan = \\\"off\\\"\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"rewrite project config: %v\", err)\n\t}\n\tcfg, err = config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"reload config: %v\", err)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want user-level on despite project off\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only the configured default is keyed\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers,\n\t\tconfig.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t\tconfig.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t)\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\n\tselected := config.Default().Providers\n\n\tinput := \"ds-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reused)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reset)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), io.Discard)\n\tif len(env) != 1 {\n\t\tt.Errorf(\"env = %v, want 1 (DeepSeek reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro), preserving the order each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" {\n\t\tt.Errorf(\"display name = %q\", info[\"deepseek\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesDoesNotAddMissingMimo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] {\n\t\tt.Fatalf(\"wizard families = %v, want DeepSeek\", order)\n\t}\n\tif seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, should not inject MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\n// TestWithBuiltinFamiliesRestoresSiblingEntries covers the re-run scenario:\n// a user previously selected only deepseek-v4-flash (saved as deepseek-flash\n// with a single model). Re-running `reasonix setup` must still surface the\n// sibling deepseek-pro entry so the user can pick deepseek-v4-pro too,\n// rather than only showing the previously selected model.\nfunc TestWithBuiltinFamiliesRestoresSiblingEntries(t *testing.T) {\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Models: []string{\"deepseek-v4-flash\"}, APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tgot := withBuiltinFamilies(cfg)\n\n\t// deepseek-pro must be restored even though deepseek family already exists.\n\tvar found bool\n\tfor _, p := range got {\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"withBuiltinFamilies(%+v) = %v, want deepseek-pro sibling restored\", cfg, namesOf(got))\n\t}\n\n\t// The static model list for the deepseek family must include both SKUs.\n\t_, members, _ := groupByFamily(got)\n\tdeepseekIdxs := members[\"deepseek\"]\n\tmodels := familyStaticModels(got, deepseekIdxs)\n\twantModels := map[string]bool{\"deepseek-v4-flash\": true, \"deepseek-v4-pro\": true}\n\tfor _, m := range models {\n\t\tdelete(wantModels, m)\n\t}\n\tif len(wantModels) > 0 {\n\t\tt.Errorf(\"familyStaticModels = %v, missing %v\", models, wantModels)\n\t}\n}\n\nfunc namesOf(ps []config.ProviderEntry) []string {\n\tout := make([]string, len(ps))\n\tfor i, p := range ps {\n\t\tout[i] = p.Name\n\t}\n\treturn out\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "d9e453a1bedd675c226660cab033da35421b141d": {"t": 1785750402.55116, "n": 44543, "h": "838c1d73358d85f7", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestLoadResumableSessionRejectsCleanupPending(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"pending.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := loadResumableSession(path); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"loadResumableSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n}\n\nfunc TestRunResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-run.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runAgent([]string{\"--resume\", path, \"continue task\"}); rc != 1 {\n\t\t\tt.Fatalf(\"run --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"run --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestServeResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-serve.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--resume\", path, \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"serve --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tcwd := mustGetwd(t)\n\tif err := os.WriteFile(filepath.Join(cwd, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"cwd-project\"\ncommand = \"cwd-project-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmigrateLegacyConfigForCLI()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"early CLI legacy migration imported the cwd project plugin: %+v\", cfg.Plugins)\n\t}\n\n\tmigrateMCPConfigForCLIWorkspace()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"workspace-aware CLI migration did not import project plugin: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc hasPluginNamed(cfg *config.Config, name string) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tfor _, plugin := range cfg.Plugins {\n\t\tif plugin.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only the configured default is keyed\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers,\n\t\tconfig.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t\tconfig.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"},\n\t)\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-flash\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\n\tselected := config.Default().Providers\n\n\tinput := \"ds-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reused)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\n\"), &output)\n\n\tif len(env) != 1 {\n\t\tt.Fatalf(\"env = %v (want 1: DeepSeek reset)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\"), io.Discard)\n\tif len(env) != 1 {\n\t\tt.Errorf(\"env = %v, want 1 (DeepSeek reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro), preserving the order each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" {\n\t\tt.Errorf(\"display name = %q\", info[\"deepseek\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesDoesNotAddMissingMimo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] {\n\t\tt.Fatalf(\"wizard families = %v, want DeepSeek\", order)\n\t}\n\tif seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, should not inject MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\n// TestWithBuiltinFamiliesRestoresSiblingEntries covers the re-run scenario:\n// a user previously selected only deepseek-v4-flash (saved as deepseek-flash\n// with a single model). Re-running `reasonix setup` must still surface the\n// sibling deepseek-pro entry so the user can pick deepseek-v4-pro too,\n// rather than only showing the previously selected model.\nfunc TestWithBuiltinFamiliesRestoresSiblingEntries(t *testing.T) {\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Models: []string{\"deepseek-v4-flash\"}, APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tgot := withBuiltinFamilies(cfg)\n\n\t// deepseek-pro must be restored even though deepseek family already exists.\n\tvar found bool\n\tfor _, p := range got {\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"withBuiltinFamilies(%+v) = %v, want deepseek-pro sibling restored\", cfg, namesOf(got))\n\t}\n\n\t// The static model list for the deepseek family must include both SKUs.\n\t_, members, _ := groupByFamily(got)\n\tdeepseekIdxs := members[\"deepseek\"]\n\tmodels := familyStaticModels(got, deepseekIdxs)\n\twantModels := map[string]bool{\"deepseek-v4-flash\": true, \"deepseek-v4-pro\": true}\n\tfor _, m := range models {\n\t\tdelete(wantModels, m)\n\t}\n\tif len(wantModels) > 0 {\n\t\tt.Errorf(\"familyStaticModels = %v, missing %v\", models, wantModels)\n\t}\n}\n\nfunc namesOf(ps []config.ProviderEntry) []string {\n\tout := make([]string, len(ps))\n\tfor i, p := range ps {\n\t\tout[i] = p.Name\n\t}\n\treturn out\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: \"mimo-pro\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Model: \"mimo-v2.5-pro\", APIKeyEnv: \"MIMO_API_KEY\"})\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "bd19a28c42cd3aaf7930ba243c1a32f3bc719254": {"t": 1785750403.88709, "n": 44613, "h": "b6732ad0af622e32", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestLoadResumableSessionRejectsCleanupPending(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"pending.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif _, err := loadResumableSession(path); err == nil || !strings.Contains(err.Error(), \"pending cleanup\") {\n\t\tt.Fatalf(\"loadResumableSession cleanup-pending error = %v, want pending cleanup\", err)\n\t}\n}\n\nfunc TestRunResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-run.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runAgent([]string{\"--resume\", path, \"continue task\"}); rc != 1 {\n\t\t\tt.Fatalf(\"run --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"run --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestServeResumeRejectsCleanupPending(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tpath := filepath.Join(t.TempDir(), \"pending-serve.jsonl\")\n\tsaveTestSession(t, path, \"pending prompt\")\n\tif err := agent.MarkCleanupPending(path, \"delete\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := runServe([]string{\"--resume\", path, \"--addr\", \"127.0.0.1:0\"}); rc != 1 {\n\t\t\tt.Fatalf(\"serve --resume cleanup-pending rc = %d, want 1\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"pending cleanup\") {\n\t\tt.Fatalf(\"serve --resume cleanup-pending stderr = %q, want pending cleanup\", errOut)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tcwd := mustGetwd(t)\n\tif err := os.WriteFile(filepath.Join(cwd, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"cwd-project\"\ncommand = \"cwd-project-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmigrateLegacyConfigForCLI()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"early CLI legacy migration imported the cwd project plugin: %+v\", cfg.Plugins)\n\t}\n\n\tmigrateMCPConfigForCLIWorkspace()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"workspace-aware CLI migration did not import project plugin: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc hasPluginNamed(cfg *config.Config, name string) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tfor _, plugin := range cfg.Plugins {\n\t\tif plugin.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\n// TestWithBuiltinFamiliesRestoresSiblingEntries covers the re-run scenario:\n// a user previously selected only deepseek-v4-flash (saved as deepseek-flash\n// with a single model). Re-running `reasonix setup` must still surface the\n// sibling deepseek-pro entry so the user can pick deepseek-v4-pro too,\n// rather than only showing the previously selected model.\nfunc TestWithBuiltinFamiliesRestoresSiblingEntries(t *testing.T) {\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Models: []string{\"deepseek-v4-flash\"}, APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tgot := withBuiltinFamilies(cfg)\n\n\t// deepseek-pro must be restored even though deepseek family already exists.\n\tvar found bool\n\tfor _, p := range got {\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"withBuiltinFamilies(%+v) = %v, want deepseek-pro sibling restored\", cfg, namesOf(got))\n\t}\n\n\t// The static model list for the deepseek family must include both SKUs.\n\t_, members, _ := groupByFamily(got)\n\tdeepseekIdxs := members[\"deepseek\"]\n\tmodels := familyStaticModels(got, deepseekIdxs)\n\twantModels := map[string]bool{\"deepseek-v4-flash\": true, \"deepseek-v4-pro\": true}\n\tfor _, m := range models {\n\t\tdelete(wantModels, m)\n\t}\n\tif len(wantModels) > 0 {\n\t\tt.Errorf(\"familyStaticModels = %v, missing %v\", models, wantModels)\n\t}\n}\n\nfunc namesOf(ps []config.ProviderEntry) []string {\n\tout := make([]string, len(ps))\n\tfor i, p := range ps {\n\t\tout[i] = p.Name\n\t}\n\treturn out\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "b9c7af33731c65feefca76ec6795e0030ee66660": {"t": 1785750405.1880364, "n": 42943, "h": "8d08e5d971ac8b4b", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tcwd := mustGetwd(t)\n\tif err := os.WriteFile(filepath.Join(cwd, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"cwd-project\"\ncommand = \"cwd-project-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmigrateLegacyConfigForCLI()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"early CLI legacy migration imported the cwd project plugin: %+v\", cfg.Plugins)\n\t}\n\n\tmigrateMCPConfigForCLIWorkspace()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"workspace-aware CLI migration did not import project plugin: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc hasPluginNamed(cfg *config.Config, name string) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tfor _, plugin := range cfg.Plugins {\n\t\tif plugin.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\n// TestWithBuiltinFamiliesRestoresSiblingEntries covers the re-run scenario:\n// a user previously selected only deepseek-v4-flash (saved as deepseek-flash\n// with a single model). Re-running `reasonix setup` must still surface the\n// sibling deepseek-pro entry so the user can pick deepseek-v4-pro too,\n// rather than only showing the previously selected model.\nfunc TestWithBuiltinFamiliesRestoresSiblingEntries(t *testing.T) {\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Models: []string{\"deepseek-v4-flash\"}, APIKeyEnv: \"DEEPSEEK_API_KEY\"},\n\t}\n\tgot := withBuiltinFamilies(cfg)\n\n\t// deepseek-pro must be restored even though deepseek family already exists.\n\tvar found bool\n\tfor _, p := range got {\n\t\tif p.Name == \"deepseek-pro\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"withBuiltinFamilies(%+v) = %v, want deepseek-pro sibling restored\", cfg, namesOf(got))\n\t}\n\n\t// The static model list for the deepseek family must include both SKUs.\n\t_, members, _ := groupByFamily(got)\n\tdeepseekIdxs := members[\"deepseek\"]\n\tmodels := familyStaticModels(got, deepseekIdxs)\n\twantModels := map[string]bool{\"deepseek-v4-flash\": true, \"deepseek-v4-pro\": true}\n\tfor _, m := range models {\n\t\tdelete(wantModels, m)\n\t}\n\tif len(wantModels) > 0 {\n\t\tt.Errorf(\"familyStaticModels = %v, missing %v\", models, wantModels)\n\t}\n}\n\nfunc namesOf(ps []config.ProviderEntry) []string {\n\tout := make([]string, len(ps))\n\tfor i, p := range ps {\n\t\tout[i] = p.Name\n\t}\n\treturn out\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "ad1bee0170a0a51c98ad5c973ae183740d14dd7b": {"t": 1785750406.9222028, "n": 41387, "h": "8a8edfc4ab1ddb4b", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tcwd := mustGetwd(t)\n\tif err := os.WriteFile(filepath.Join(cwd, \"reasonix.toml\"), []byte(`\n[[plugins]]\nname = \"cwd-project\"\ncommand = \"cwd-project-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmigrateLegacyConfigForCLI()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"early CLI legacy migration imported the cwd project plugin: %+v\", cfg.Plugins)\n\t}\n\n\tmigrateMCPConfigForCLIWorkspace()\n\tif cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, \"cwd-project\") {\n\t\tt.Fatalf(\"workspace-aware CLI migration did not import project plugin: %+v\", cfg.Plugins)\n\t}\n}\n\nfunc hasPluginNamed(cfg *config.Config, name string) bool {\n\tif cfg == nil {\n\t\treturn false\n\t}\n\tfor _, plugin := range cfg.Plugins {\n\t\tif plugin.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "0cfc05cbe3c7c15ab0021234f6d22766441f4824": {"t": 1785750408.2222393, "n": 40465, "h": "b2ad5de0694b1e27", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return true }\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tprevInteractive := cliIsInteractive\n\tt.Cleanup(func() {\n\t\trunInteractiveSession = prev\n\t\tcliIsInteractive = prevInteractive\n\t})\n\tcliIsInteractive = func() bool { return false }\n\trunInteractiveSession = func(args []string) int {\n\t\tt.Fatalf(\"non-interactive no-arg Run should not start session with %#v\", args)\n\t\treturn 99\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run(nil, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"Run(nil) rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix —\") || !strings.Contains(out, \"reasonix run\") {\n\t\tt.Fatalf(\"non-interactive no-arg Run should print usage, got:\\n%s\", out)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tfor _, args := range [][]string{\n\t\t{\"--continue\"},\n\t\t{\"--continue=true\"},\n\t\t{\"-c=true\"},\n\t\t{\"--resume=true\"},\n\t\t{\"--yolo=true\"},\n\t\t{\"--dangerously-skip-permissions=true\"},\n\t} {\n\t\tvar gotArgs []string\n\t\trunInteractiveSession = func(args []string) int {\n\t\t\tgotArgs = append([]string(nil), args...)\n\t\t\treturn 23\n\t\t}\n\n\t\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\t\tt.Fatalf(\"Run(%#v) rc = %d, want 23\", args, rc)\n\t\t}\n\t\tif !reflect.DeepEqual(gotArgs, args) {\n\t\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t\t}\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "74d0ab6d937f30ff6c6429e1880447326186f017": {"t": 1785750410.499274, "n": 39439, "h": "f076d748ea8369dd", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunDefaultsToInteractiveSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 17\n\t}\n\n\tif rc := Run(nil, \"test-version\"); rc != 17 {\n\t\tt.Fatalf(\"Run(nil) rc = %d, want 17\", rc)\n\t}\n\tif gotArgs != nil {\n\t\tt.Fatalf(\"interactive args = %#v, want nil\", gotArgs)\n\t}\n}\n\nfunc TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar gotArgs []string\n\trunInteractiveSession = func(args []string) int {\n\t\tgotArgs = append([]string(nil), args...)\n\t\treturn 23\n\t}\n\n\targs := []string{\"--continue\"}\n\tif rc := Run(args, \"test-version\"); rc != 23 {\n\t\tt.Fatalf(\"Run(--continue) rc = %d, want 23\", rc)\n\t}\n\tif !reflect.DeepEqual(gotArgs, args) {\n\t\tt.Fatalf(\"interactive args = %#v, want %#v\", gotArgs, args)\n\t}\n}\n\nfunc TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tprev := runInteractiveSession\n\tt.Cleanup(func() { runInteractiveSession = prev })\n\n\tvar calls [][]string\n\trunInteractiveSession = func(args []string) int {\n\t\tcalls = append(calls, append([]string(nil), args...))\n\t\treturn 0\n\t}\n\n\tif rc := Run([]string{\"chat\", \"--resume\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(chat --resume) rc = %d, want 0\", rc)\n\t}\n\tif rc := Run([]string{\"code\", \"--continue\"}, \"test-version\"); rc != 0 {\n\t\tt.Fatalf(\"Run(code --continue) rc = %d, want 0\", rc)\n\t}\n\n\twant := [][]string{{\"--resume\"}, {\"--continue\"}}\n\tif !reflect.DeepEqual(calls, want) {\n\t\tt.Fatalf(\"interactive calls = %#v, want %#v\", calls, want)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "1a0258253867c5a8c73134af77ff51dd1fd4d7ab": {"t": 1785750414.0518756, "n": 38206, "h": "e57771661cc06343", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {\n\tdir := t.TempDir()\n\tpath := filepath.Join(dir, \"session.jsonl\")\n\tsession := agent.NewSession(\"sys\")\n\tsession.Add(provider.Message{Role: provider.RoleUser, Content: \"hello\"})\n\tif err := session.Save(path); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := agent.SetBranchModelPreserveUpdated(path, \"saved/model\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfg := &config.Config{\n\t\tDefaultModel: \"default/model\",\n\t\tProviders: []config.ProviderEntry{\n\t\t\t{Name: \"default\", Kind: \"openai\", BaseURL: \"https://default.invalid/v1\", Model: \"model\"},\n\t\t\t{Name: \"saved\", Kind: \"openai\", BaseURL: \"https://saved.invalid/v1\", Model: \"model\"},\n\t\t},\n\t}\n\n\tif got := modelForResumePath(\"\", path, cfg); got != \"saved/model\" {\n\t\tt.Fatalf(\"modelForResumePath = %q, want saved/model\", got)\n\t}\n\tif got := modelForResumePath(\"explicit/model\", path, cfg); got != \"explicit/model\" {\n\t\tt.Fatalf(\"explicit model was overwritten: %q\", got)\n\t}\n\tif got := modelForResumePath(\"\", filepath.Join(dir, \"missing.jsonl\"), cfg); got != \"\" {\n\t\tt.Fatalf(\"missing session model = %q, want empty fallback\", got)\n\t}\n\tcfg.Providers = cfg.Providers[:1]\n\tif got := modelForResumePath(\"\", path, cfg); got != \"\" {\n\t\tt.Fatalf(\"unknown stored model = %q, want empty fallback\", got)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "a148c43d2caa3f1a2eb6ec9cf86baf6de94f7d87": {"t": 1785750416.9308112, "n": 36885, "h": "c3e447640f45d1f5", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"REASONIX_CREDENTIALS_STORE\", \"file\")\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "655c72ad9b33266c8d6e94dd3d030abf8380ee05": {"t": 1785750418.228102, "n": 36837, "h": "4e1dcb1cc46f726b", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\traw, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttext := string(raw)\n\tfor _, forbidden := range []string{\"[codegraph]\", \"[builtin_mcp]\", \"[builtin_mcp_updates]\"} {\n\t\tif strings.Contains(text, forbidden) {\n\t\t\tt.Fatalf(\"default config should omit %s:\\n%s\", forbidden, text)\n\t\t}\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "1a87cd4cfde4b7ca269dcb893534a78bb4c90877": {"t": 1785750419.4815865, "n": 36686, "h": "5e7040df7a223ddb", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 3`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {\n\tproviders := withBuiltinFamiliesForLanguage(nil, \"zh\")\n\tvar flash *config.ProviderEntry\n\tfor i := range providers {\n\t\tif providers[i].Name == \"deepseek-flash\" {\n\t\t\tflash = &providers[i]\n\t\t\tbreak\n\t\t}\n\t}\n\tif flash == nil {\n\t\tt.Fatal(\"deepseek-flash provider missing\")\n\t}\n\tif flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != \"¥\" {\n\t\tt.Fatalf(\"flash price = %+v, want CNY preset\", flash.Price)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "c30e0e78c058d428156b05f40344e994fc13ce40": {"t": 1785750420.7723649, "n": 36190, "h": "4249496e6ba02591", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {\n\tvar b bytes.Buffer\n\tprepareNativeScrollback(&b, 2)\n\tif got, want := b.String(), \"\\x1B[3J\\x1B[2J\\x1B[H\\n\\n\"; got != want {\n\t\tt.Fatalf(\"prepareNativeScrollback wrote %q, want %q\", got, want)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "f6d8cce7eb7294a4a9e4a5c6492cb0941112e545": {"t": 1785750422.0641346, "n": 35928, "h": "f268b175ffef417c", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"zh\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"zh\"`) {\n\t\tt.Fatalf(\"config reasoning-language output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.ReasoningLanguage != \"zh\" || cfg.ReasoningLanguage() != \"zh\" {\n\t\tt.Fatalf(\"saved reasoning_language = %q/%q, want zh\", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"--local\", \"en\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config reasoning-language --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"config reasoning-language --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project reasoning-language override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `reasoning_language = \"en\"`) {\n\t\tt.Fatalf(\"project config missing reasoning_language override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.ReasoningLanguage() != \"en\" {\n\t\tt.Fatalf(\"reasoning_language = %q, want local en\", cfg.ReasoningLanguage())\n\t}\n}\n\nfunc TestConfigReasoningLanguageRejectsAliases(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"config\", \"reasoning-language\", \"中文\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"config reasoning-language alias rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"must be auto|zh|en\") {\n\t\tt.Fatalf(\"config reasoning-language alias stderr = %q\", errOut)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "b249410188d613c9ba3319d0deabd7ac0c3e1906": {"t": 1785750423.3937104, "n": 33465, "h": "cc978519aec05267", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysSkipsDisabledAutoPlanClassifier(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.AutoPlan = \"off\"\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when auto-plan classifier is disabled\", missing)\n\t}\n\n\tcfg.Agent.AutoPlan = \"on\"\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want enabled auto-plan classifier provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "8a8627e391d4a86f45433014c4cd58fe558d4080": {"t": 1785750424.6562555, "n": 32723, "h": "7263683dd6bc02cb", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\nfunc TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want only active default model provider\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"DEEPSEEK_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want DEEPSEEK_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {\n\tcfg := config.Default()\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tif missing := providersWithMissingKeys(cfg); len(missing) != 0 {\n\t\tt.Fatalf(\"missing providers = %+v, want none when only unused MiMo presets are keyless\", missing)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\tcfg.Agent.SubagentModel = \"mimo-flash\"\n\tcfg.Agent.SubagentModels = map[string]string{\n\t\t\"review\": \"mimo-pro/mimo-v2.5-pro\",\n\t}\n\tcfg.Agent.AutoPlanClassifier = \"mimo-flash/mimo-v2.5\"\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"test-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tmissing := providersWithMissingKeys(cfg)\n\tif len(missing) != 1 {\n\t\tt.Fatalf(\"missing providers = %+v, want MiMo once\", missing)\n\t}\n\tif missing[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Fatalf(\"missing key env = %q, want MIMO_API_KEY\", missing[0].APIKeyEnv)\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "dc6a1e3a86bb79f60a6fd763bcd4075365726630": {"t": 1785750425.9269328, "n": 31256, "h": "3af4cbee62e6c237", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n\nfunc TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\n\tgot := providersWithMissingKeys(cfg)\n\tenvs := map[string]bool{}\n\tfor _, p := range got {\n\t\tenvs[p.APIKeyEnv] = true\n\t}\n\tif !envs[\"DEEPSEEK_API_KEY\"] {\n\t\tt.Errorf(\"the default model's missing key must be prompted, got %v\", got)\n\t}\n\tif envs[\"MIMO_API_KEY\"] {\n\t\tt.Errorf(\"unreferenced preset keys must not be prompted, got %v\", got)\n\t}\n}\n\nfunc TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"set\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\tcfg := config.Default()\n\tcfg.Agent.PlannerModel = \"mimo-pro\"\n\n\tgot := providersWithMissingKeys(cfg)\n\tif len(got) != 1 || got[0].APIKeyEnv != \"MIMO_API_KEY\" {\n\t\tt.Errorf(\"planner model's missing key must be prompted, got %+v\", got)\n\t}\n}\n"}, "3fe1fa09b95bb19e73c8e7a11feed298ccf92290": {"t": 1785750427.2050624, "n": 30390, "h": "ea25657c0b50c9e0", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider\n// model probe. Previously the probe was a single URL (baseURL+\"/models\"),\n// which worked for OpenAI vendors with a /v1 base URL but silently failed\n// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies\n// (a /v1 base URL but a /v1/messages endpoint). The new helper walks\n// BuildModelFetchURLs's candidate list — root + /v1 + known compat\n// suffixes — so the same probe now succeeds for both shapes, matching\n// what the conversation-time client URL will actually be.\nfunc TestFetchModelListCompatWalksCandidates(t *testing.T) {\n\tt.Run(\"anthropic root form resolves via v1 fallback\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tif r.URL.Path == \"/v1/models\" {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"claude-test\"}]}`)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"claude-test\"}) {\n\t\t\tt.Errorf(\"models = %v, want [claude-test]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models (root form should fall through to v1 candidate)\", got)\n\t\t}\n\t})\n\n\tt.Run(\"versioned v1 base URL hits models directly\", func(t *testing.T) {\n\t\tvar gotPath atomic.Value\n\t\tgotPath.Store(\"\")\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tgotPath.Store(r.URL.Path)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t_, _ = io.WriteString(w, `{\"data\":[{\"id\":\"model-a\"}]}`)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL+\"/v1\", \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"fetchModelListCompat: %v\", err)\n\t\t}\n\t\tif !reflect.DeepEqual(models, []string{\"model-a\"}) {\n\t\t\tt.Errorf(\"models = %v, want [model-a]\", models)\n\t\t}\n\t\tif got := gotPath.Load().(string); got != \"/v1/models\" {\n\t\t\tt.Errorf(\"probe path = %q, want /v1/models\", got)\n\t\t}\n\t})\n\n\tt.Run(\"endpoint-miss on every candidate returns empty (manual flow)\", func(t *testing.T) {\n\t\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}))\n\t\tdefer srv.Close()\n\n\t\tmodels, err := fetchModelListCompat(context.Background(), srv.URL, \"k\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected graceful empty result on all-miss, got err: %v\", err)\n\t\t}\n\t\tif len(models) != 0 {\n\t\t\tt.Errorf(\"expected empty models on all-miss, got %v\", models)\n\t\t}\n\t})\n\n\tt.Run(\"non-404 network error short-circuits with the real error\", func(t *testing.T) {\n\t\t// Point at a closed port — connection refused, not a 404.\n\t\tmodels, err := fetchModelListCompat(context.Background(), \"http://127.0.0.1:1\", \"k\")\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for unreachable host, got models=%v\", models)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n"}, "b73ce718a8bbbe164dd0f0df43f26fbe94c70c90": {"t": 1785750428.4280922, "n": 27173, "h": "10cc38cb479f80bc", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {\n\tvar b bytes.Buffer\n\treserveNativeScrollbackFrame(&b, 3)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame wrote %q, want only three newlines\", got)\n\t}\n\n\treserveNativeScrollbackFrame(&b, 0)\n\tif got := b.String(); got != \"\\n\\n\\n\" {\n\t\tt.Fatalf(\"reserveNativeScrollbackFrame(0) changed output to %q\", got)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n"}, "58f444a6926bf130ec343efe89786202ec52275a": {"t": 1785750429.6526496, "n": 26760, "h": "d5954f900a66f56d", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") && !strings.Contains(out, \"用法:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n\tif !strings.Contains(out, \"reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] \") {\n\t\tt.Fatalf(\"help output missing run resume flags:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n"}, "e32d1678421482716d92ba5154cf441b6ca3af11": {"t": 1785750430.9059258, "n": 26547, "h": "7c3d0d299b753cf1", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\nfunc TestRunDispatchesACPLongFlagAlias(t *testing.T) {\n\terrOut := captureStderr(t, func() {\n\t\tif rc := Run([]string{\"--acp\", \"-h\"}, \"test-version\"); rc != 2 {\n\t\t\tt.Fatalf(\"Run --acp -h rc = %d, want 2\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(errOut, \"Usage of acp:\") {\n\t\tt.Fatalf(\"--acp should dispatch to the ACP command, got stderr:\\n%s\", errOut)\n\t}\n\tif strings.Contains(errOut, \"unknown command\") {\n\t\tt.Fatalf(\"--acp should not be treated as an unknown command:\\n%s\", errOut)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n\nfunc captureStderr(t *testing.T, fn func()) string {\n\tt.Helper()\n\told := os.Stderr\n\tr, w, err := os.Pipe()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tos.Stderr = w\n\tdefer func() { os.Stderr = old }()\n\n\tfn()\n\tif err := w.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdata, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn string(data)\n}\n"}, "c63941da2280bb902dbe7766060286660e3dd109": {"t": 1785750432.1404555, "n": 25727, "h": "c5b2380357d7caed", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"\\nmi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\nfunc TestConfigureKeysCanResetExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"stale-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one normally\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"y\\nfresh-ds-key\\nmi-key\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reset + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=fresh-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want freshly entered value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q, want typed MiMo value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"[y/N]:\") || !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a reset confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var\n// is already populated, pressing Enter at each confirmation keeps the values.\nfunc TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"\\n\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n"}, "9b603cf3a877f21a142840925491fd8f277f47af": {"t": 1785750433.341829, "n": 25035, "h": "55f330cbbc32343d", "c": "package cli\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\nfunc TestSetupOverwritePromptShowsYNDefault(t *testing.T) {\n\tt.Cleanup(func() { i18n.DetectLanguage(\"en\") })\n\tfor _, lang := range []string{\"en\", \"zh\"} {\n\t\ti18n.DetectLanguage(lang)\n\t\tvar out bytes.Buffer\n\t\tif confirmReconfigureExistingConfig(\"config.toml\", bufio.NewScanner(strings.NewReader(\"\\n\")), &out) {\n\t\t\tt.Fatalf(\"%s empty overwrite answer should keep existing config\", lang)\n\t\t}\n\t\tif !strings.Contains(out.String(), \"[y/N]:\") {\n\t\t\tt.Fatalf(\"%s overwrite prompt should show explicit [y/N] default, got %q\", lang, out.String())\n\t\t}\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"mi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetSkipsInput ensures that when every env var is\n// already populated, configureKeys returns without reading anything from\n// the input — critical for the first-time-setup flow, where the URL-fetch\n// step has already collected all keys and configureKeys is a no-op.\nfunc TestConfigureKeysAllSetSkipsInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"should-not-be-consumed\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n"}, "3cec9768f47d9c12435a42dca7618e75155e536a": {"t": 1785750434.5587852, "n": 24455, "h": "fb7513a67ce157f9", "c": "package cli\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/notify\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\ntype cliRecordSink struct {\n\tevents []event.Kind\n}\n\nfunc (s *cliRecordSink) Emit(e event.Event) {\n\ts.events = append(s.events, e.Kind)\n}\n\ntype cliRecordSender struct {\n\tmessages []notify.Message\n}\n\nfunc (s *cliRecordSender) Send(m notify.Message) error {\n\ts.messages = append(s.messages, m)\n\treturn nil\n}\n\nfunc TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {\n\tinner := &cliRecordSink{}\n\tsender := &cliRecordSender{}\n\tcalls := 0\n\tprev := newNotificationSender\n\tnewNotificationSender = func() notify.Sender {\n\t\tcalls++\n\t\treturn sender\n\t}\n\tt.Cleanup(func() { newNotificationSender = prev })\n\n\tcfg := config.Default()\n\tcfg.Notifications.Enabled = true\n\n\twrapped := withNotifications(inner, cfg)\n\twrapped.Emit(event.Event{Kind: event.TurnDone})\n\n\tif calls != 1 {\n\t\tt.Fatalf(\"newNotificationSender calls = %d, want 1\", calls)\n\t}\n\tif len(inner.events) != 1 || inner.events[0] != event.TurnDone {\n\t\tt.Fatalf(\"forwarded events = %v, want [TurnDone]\", inner.events)\n\t}\n\tif len(sender.messages) != 1 {\n\t\tt.Fatalf(\"notifications = %d, want 1\", len(sender.messages))\n\t}\n\tif sender.messages[0].Body != \"Turn finished\" {\n\t\tt.Fatalf(\"notification body = %q, want Turn finished\", sender.messages[0].Body)\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"mi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetSkipsInput ensures that when every env var is\n// already populated, configureKeys returns without reading anything from\n// the input — critical for the first-time-setup flow, where the URL-fetch\n// step has already collected all keys and configureKeys is a no-op.\nfunc TestConfigureKeysAllSetSkipsInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"should-not-be-consumed\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n"}, "e73cc73efc69a88ab106c7783188687b77ba70b7": {"t": 1785750435.8010015, "n": 23184, "h": "7a104e1baa84caab", "c": "package cli\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestWelcomePromptMissingKeysRequiresConfigSource(t *testing.T) {\n\tif welcomeShouldPromptMissingKeys(\"\", nil) {\n\t\tt.Fatal(\"built-in defaults without a config source should not prompt for missing provider keys\")\n\t}\n\tif welcomeShouldPromptMissingKeys(\"reasonix.toml\", errors.New(\"bad config\")) {\n\t\tt.Fatal(\"invalid config should not enter the missing-key prompt path\")\n\t}\n\tif !welcomeShouldPromptMissingKeys(\"reasonix.toml\", nil) {\n\t\tt.Fatal(\"valid config source should enter the missing-key prompt path\")\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"mi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetSkipsInput ensures that when every env var is\n// already populated, configureKeys returns without reading anything from\n// the input — critical for the first-time-setup flow, where the URL-fetch\n// step has already collected all keys and configureKeys is a no-op.\nfunc TestConfigureKeysAllSetSkipsInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"should-not-be-consumed\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n"}, "a4db571df079b643cd1660a79f04251d07ef0692": {"t": 1785750437.0354238, "n": 22660, "h": "70ecc83b740371c7", "c": "package cli\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\nfunc TestConfigAutoPlanCommandWritesUserConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan output = %q\", out)\n\t}\n\tcfg := config.LoadForEdit(config.UserConfigPath())\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"saved auto_plan = %q, want on\", cfg.Agent.AutoPlan)\n\t}\n}\n\nfunc TestConfigAutoPlanLocalCreatesMinimalProjectOverride(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\n\tuserCfg := config.Default()\n\tuserCfg.DefaultModel = \"mimo-pro\"\n\tif err := userCfg.SaveTo(config.UserConfigPath()); err != nil {\n\t\tt.Fatalf(\"write user config: %v\", err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"config\", \"auto-plan\", \"--local\", \"on\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"config auto-plan --local rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"config auto-plan --local output = %q\", out)\n\t}\n\n\tbody, err := os.ReadFile(\"reasonix.toml\")\n\tif err != nil {\n\t\tt.Fatalf(\"read project config: %v\", err)\n\t}\n\tif strings.Contains(string(body), \"default_model\") {\n\t\tt.Fatalf(\"project auto-plan override should not pin default_model:\\n%s\", body)\n\t}\n\tif !strings.Contains(string(body), \"[agent]\") || !strings.Contains(string(body), `auto_plan = \"on\"`) {\n\t\tt.Fatalf(\"project config missing auto_plan override:\\n%s\", body)\n\t}\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tt.Fatalf(\"load merged config: %v\", err)\n\t}\n\tif cfg.DefaultModel != \"mimo-pro\" {\n\t\tt.Fatalf(\"default_model = %q, want global mimo-pro\", cfg.DefaultModel)\n\t}\n\tif cfg.Agent.AutoPlan != \"on\" {\n\t\tt.Fatalf(\"auto_plan = %q, want local on\", cfg.Agent.AutoPlan)\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"mi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetSkipsInput ensures that when every env var is\n// already populated, configureKeys returns without reading anything from\n// the input — critical for the first-time-setup flow, where the URL-fetch\n// step has already collected all keys and configureKeys is a no-op.\nfunc TestConfigureKeysAllSetSkipsInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"should-not-be-consumed\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n"}, "b87a878cadd4d99d9e2da988606e2892718bbe05": {"t": 1785750438.2606468, "n": 20840, "h": "fe5d7768851d0aee", "c": "package cli\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc isolateCLIConfigHome(t *testing.T) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(t.TempDir())\n\treturn home\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\nfunc TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`\ndefault_model = \"deepseek-flash\"\n\n[[plugins]]\nname = \"legacy-cli\"\ncommand = \"legacy-bin\"\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"mcp\", \"list\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"mcp list rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"legacy-cli\") {\n\t\tt.Fatalf(\"mcp list should include migrated legacy config:\\n%s\", out)\n\t}\n\n\tbody, err := os.ReadFile(config.UserConfigPath())\n\tif err != nil {\n\t\tt.Fatalf(\"read migrated user config: %v\", err)\n\t}\n\tfor _, want := range []string{`config_version = 2`, `[desktop]`, `name = \"legacy-cli\"`} {\n\t\tif !strings.Contains(string(body), want) {\n\t\t\tt.Fatalf(\"migrated config missing %q:\\n%s\", want, body)\n\t\t}\n\t}\n}\n\nfunc TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {\n\tisolateCLIConfigHome(t)\n\tlegacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), \"reasonix.toml\")\n\tif err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(legacyPath, []byte(`default_model = \"deepseek-flash\"`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\tif _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"version should not migrate legacy config, stat err=%v\", err)\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"mi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetSkipsInput ensures that when every env var is\n// already populated, configureKeys returns without reading anything from\n// the input — critical for the first-time-setup flow, where the URL-fetch\n// step has already collected all keys and configureKeys is a no-op.\nfunc TestConfigureKeysAllSetSkipsInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"should-not-be-consumed\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n"}, "7264bb78720174fdaf967cd7b08306cde37c2d9d": {"t": 1785750439.3734856, "n": 18723, "h": "7436f8caa5079128", "c": "package cli\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"mi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetSkipsInput ensures that when every env var is\n// already populated, configureKeys returns without reading anything from\n// the input — critical for the first-time-setup flow, where the URL-fetch\n// step has already collected all keys and configureKeys is a no-op.\nfunc TestConfigureKeysAllSetSkipsInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"should-not-be-consumed\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n\nfunc TestWriteDefaultConfigDisablesCodegraph(t *testing.T) {\n\tpath := filepath.Join(t.TempDir(), \"reasonix.toml\")\n\tif rc := writeDefaultConfig(path); rc != 0 {\n\t\tt.Fatalf(\"writeDefaultConfig rc = %d\", rc)\n\t}\n\tif c := config.LoadForEdit(path); c.Codegraph.Enabled {\n\t\tt.Fatal(\"a freshly scaffolded config left codegraph enabled; new users should start without it\")\n\t}\n}\n"}, "e3b8a41cfa3773f253b64837cd686943ca10ba1d": {"t": 1785750440.5163312, "n": 18353, "h": "3e92663bc0a0337f", "c": "package cli\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestChdirTo(t *testing.T) {\n\torig, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif rc := chdirTo(\"\"); rc != 0 {\n\t\tt.Fatalf(`chdirTo(\"\") = %d, want 0`, rc)\n\t}\n\tif cwd, _ := os.Getwd(); cwd != orig {\n\t\tt.Fatalf(`chdirTo(\"\") moved cwd to %q`, cwd)\n\t}\n\n\ttmp := t.TempDir()\n\t// Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't\n\t// delete a directory that is still the process working directory.\n\tt.Cleanup(func() { _ = os.Chdir(orig) })\n\tif rc := chdirTo(tmp); rc != 0 {\n\t\tt.Fatalf(\"chdirTo(tmp) = %d, want 0\", rc)\n\t}\n\tgot, _ := filepath.EvalSymlinks(mustGetwd(t))\n\twant, _ := filepath.EvalSymlinks(tmp)\n\tif got != want {\n\t\tt.Fatalf(\"cwd = %q, want %q\", got, want)\n\t}\n\n\tif rc := chdirTo(filepath.Join(tmp, \"does-not-exist\")); rc != 2 {\n\t\tt.Fatalf(\"chdirTo(missing) = %d, want 2\", rc)\n\t}\n}\n\nfunc mustGetwd(t *testing.T) string {\n\tt.Helper()\n\tcwd, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn cwd\n}\n\nfunc TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {\n\tdefer func(prev func() (terminalRGB, bool)) {\n\t\tqueryTerminalBackgroundForTheme = prev\n\t}(queryTerminalBackgroundForTheme)\n\tqueryTerminalBackgroundForTheme = func() (terminalRGB, bool) {\n\t\tt.Fatal(\"metadata command should not query terminal background\")\n\t\treturn terminalRGB{}, false\n\t}\n\n\tout := captureStdout(t, func() {\n\t\tif rc := Run([]string{\"version\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"version rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"reasonix test-version\") {\n\t\tt.Fatalf(\"version output = %q\", out)\n\t}\n\n\tout = captureStdout(t, func() {\n\t\tif rc := Run([]string{\"help\"}, \"test-version\"); rc != 0 {\n\t\t\tt.Fatalf(\"help rc = %d, want 0\", rc)\n\t\t}\n\t})\n\tif !strings.Contains(out, \"Usage:\") {\n\t\tt.Fatalf(\"help output missing usage:\\n%s\", out)\n\t}\n}\n\n// TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use\n// the same env var) is asked only once, and entered keys become env lines.\nfunc TestConfigureKeys(t *testing.T) {\n\t// Force a clean baseline: any DEEPSEEK_API_KEY / MIMO_API_KEY in the\n\t// process env (e.g. inherited from the test runner) would be picked up\n\t// by the new \"reuse existing\" path and the prompt would be skipped,\n\t// making the assertion below noisy.\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\")\n\n\tselected := config.Default().Providers // deepseek-flash, deepseek-pro, mimo-pro, mimo-flash\n\n\t// Two distinct keys to enter: DEEPSEEK_API_KEY, then MIMO_API_KEY.\n\tinput := \"ds-key\\nmi-key\\n\"\n\tenv := configureKeys(selected, strings.NewReader(input), io.Discard)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek asked once + MiMo asked once)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=ds-key\" {\n\t\tt.Errorf(\"env[0] = %q\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key\" {\n\t\tt.Errorf(\"env[1] = %q\", env)\n\t}\n}\n\n// TestConfigureKeysReusesExistingEnv covers the \"user already typed the key\n// in the URL-fetch flow, don't ask again\" path. When the env var is set\n// (either from .env or from a prior os.Setenv in the wizard), configureKeys\n// must NOT consume from the input stream — otherwise the user's next typed\n// line bleeds into the next provider's prompt. It also must include the\n// existing value in envLines so the value is re-pinned into .env on\n// re-runs of setup.\nfunc TestConfigureKeysReusesExistingEnv(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"preset-ds-key\")\n\tt.Setenv(\"MIMO_API_KEY\", \"\") // ask for this one\n\n\tselected := config.Default().Providers\n\tvar output bytes.Buffer\n\tenv := configureKeys(selected, strings.NewReader(\"mi-key-from-input\\n\"), &output)\n\n\tif len(env) != 2 {\n\t\tt.Fatalf(\"env = %v (want 2: DeepSeek reused + MiMo entered)\", env)\n\t}\n\tif env[0] != \"DEEPSEEK_API_KEY=preset-ds-key\" {\n\t\tt.Errorf(\"env[0] = %q, want re-pinned existing value\", env[0])\n\t}\n\tif env[1] != \"MIMO_API_KEY=mi-key-from-input\" {\n\t\tt.Errorf(\"env[1] = %q, want typed value\", env[1])\n\t}\n\tif !strings.Contains(output.String(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\\n%s\", output.String())\n\t}\n}\n\n// TestConfigureKeysAllSetSkipsInput ensures that when every env var is\n// already populated, configureKeys returns without reading anything from\n// the input — critical for the first-time-setup flow, where the URL-fetch\n// step has already collected all keys and configureKeys is a no-op.\nfunc TestConfigureKeysAllSetSkipsInput(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"ds\")\n\tt.Setenv(\"MIMO_API_KEY\", \"mi\")\n\n\tselected := config.Default().Providers\n\tenv := configureKeys(selected, strings.NewReader(\"should-not-be-consumed\\n\"), io.Discard)\n\tif len(env) != 2 {\n\t\tt.Errorf(\"env = %v, want 2 (both reused)\", env)\n\t}\n}\n\n// TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the\n// wizard with a corrected key would append a second line for the same env\n// var. loadDotEnv is first-wins, so without dedupe the stale key kept\n// authenticating, and the user saw a 401 with no obvious cause.\nfunc TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // also covers the os.Setenv pin path\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"# initial\\nDEEPSEEK_API_KEY=stale\\nMIMO_API_KEY=keepme\\n\"), 0o600)\n\n\tif err := appendEnv(p, []string{\"DEEPSEEK_API_KEY=fresh\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\twant := \"# initial\\nMIMO_API_KEY=keepme\\nDEEPSEEK_API_KEY=fresh\\n\"\n\tif string(got) != want {\n\t\tt.Errorf(\"after upsert =\\n%s\\nwant =\\n%s\", got, want)\n\t}\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"fresh\" {\n\t\tt.Errorf(\"process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)\", got, \"fresh\")\n\t}\n}\n\n// TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines\n// also get replaced, since users might hand-edit .env in shell-friendly form.\nfunc TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {\n\tt.Setenv(\"FOO\", \"\")\n\tp := filepath.Join(t.TempDir(), \".env\")\n\tos.WriteFile(p, []byte(\"export FOO=old\\nKEEP=yes\\n\"), 0o600)\n\tif err := appendEnv(p, []string{\"FOO=new\"}); err != nil {\n\t\tt.Fatalf(\"appendEnv: %v\", err)\n\t}\n\tgot, _ := os.ReadFile(p)\n\tif !strings.Contains(string(got), \"FOO=new\") || strings.Contains(string(got), \"FOO=old\") {\n\t\tt.Errorf(\"export-prefixed line not replaced:\\n%s\", got)\n\t}\n}\n\n// TestGroupByFamily verifies the wizard groups the default preset into\n// \"deepseek\" (flash + pro) and \"mimo\" (pro + flash), preserving the order\n// each family first appears in.\nfunc TestGroupByFamily(t *testing.T) {\n\torder, members, info := groupByFamily(config.Default().Providers)\n\n\tif got := order; !reflect.DeepEqual(got, []string{\"deepseek\", \"mimo\"}) {\n\t\tt.Fatalf(\"family order = %v, want [deepseek mimo]\", got)\n\t}\n\tif got := members[\"deepseek\"]; !reflect.DeepEqual(got, []int{0, 1}) {\n\t\tt.Errorf(\"deepseek members = %v, want [0 1]\", got)\n\t}\n\tif got := members[\"mimo\"]; !reflect.DeepEqual(got, []int{2, 3}) {\n\t\tt.Errorf(\"mimo members = %v, want [2 3]\", got)\n\t}\n\tif info[\"deepseek\"].name != \"DeepSeek\" || info[\"mimo\"].name != \"MiMo (Xiaomi)\" {\n\t\tt.Errorf(\"display names = %q / %q\", info[\"deepseek\"].name, info[\"mimo\"].name)\n\t}\n}\n\n// TestFetchOrFallbackLiveReturns covers the happy path: a live /models call\n// succeeds and its result wins over the preset's static list. We can't run\n// the real probe (no key) so the FetchModels call is expected to 401 and the\n// fallback path runs; the assertion below is that fallback works (static\n// list returned) and that an empty base URL short-circuits to the static\n// list with no network call.\nfunc TestFetchOrFallback(t *testing.T) {\n\tt.Run(\"empty base URL returns static list\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"\",\n\t\t\tModels: []string{\"preset-a\", \"preset-b\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\", \"preset-b\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a/b\", got)\n\t\t}\n\t})\n\n\tt.Run(\"no key set returns static list (offline first-run)\", func(t *testing.T) {\n\t\tt.Setenv(\"REASONIX_FETCH_TEST_KEY\", \"\")\n\t\tprobe := config.ProviderEntry{\n\t\t\tBaseURL: \"http://127.0.0.1:1\", // unreachable, no listener\n\t\t\tAPIKeyEnv: \"REASONIX_FETCH_TEST_KEY\",\n\t\t\tModels: []string{\"preset-a\"},\n\t\t}\n\t\tgot := fetchOrFallback(&probe, \"Test\")\n\t\tif !reflect.DeepEqual(got, []string{\"preset-a\"}) {\n\t\t\tt.Errorf(\"got %v, want preset-a\", got)\n\t\t}\n\t})\n}\n\n// TestFamilyStaticModels proves the offline fallback unions every member of a\n// family (the flash + pro SKUs), not just the first — the regression that left\n// users with only flash when the live /models probe failed.\nfunc TestFamilyStaticModels(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\"},\n\t\t{Name: \"deepseek-pro\", Model: \"deepseek-v4-pro\"},\n\t\t{Name: \"mimo-flash\", Model: \"mimo-v2.5\"},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\twant := []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Errorf(\"got %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFamilyStaticModelsDedupes(t *testing.T) {\n\tproviders := []config.ProviderEntry{\n\t\t{Name: \"a\", Models: []string{\"x\", \"y\"}},\n\t\t{Name: \"b\", Models: []string{\"y\", \"z\"}},\n\t}\n\tgot := familyStaticModels(providers, []int{0, 1})\n\tif !reflect.DeepEqual(got, []string{\"x\", \"y\", \"z\"}) {\n\t\tt.Errorf(\"got %v, want x/y/z deduped\", got)\n\t}\n}\n\n// TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate\n// entries carrying their own price, rather than collapsing into one entry that\n// would bill pro at flash's rate.\nfunc TestBuildFamilyEntriesSplitsPricing(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1, Output: 2}}\n\tpro := config.ProviderEntry{Name: \"deepseek-pro\", BaseURL: \"https://api.deepseek.com\", Model: \"deepseek-v4-pro\", Price: &provider.Pricing{Input: 3, Output: 6}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"got %d entries, want 2\", len(got))\n\t}\n\tbyName := map[string]config.ProviderEntry{}\n\tfor _, e := range got {\n\t\tbyName[e.Name] = e\n\t}\n\tif e := byName[\"deepseek-flash\"]; e.Model != \"deepseek-v4-flash\" || e.Price == nil || e.Price.Output != 2 {\n\t\tt.Errorf(\"flash entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n\tif e := byName[\"deepseek-pro\"]; e.Model != \"deepseek-v4-pro\" || e.Price == nil || e.Price.Output != 6 {\n\t\tt.Errorf(\"pro entry wrong: %+v (price %+v)\", e, e.Price)\n\t}\n}\n\n// TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching\n// preset) under the probe entry rather than dropping it.\nfunc TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {\n\tflash := config.ProviderEntry{Name: \"deepseek-flash\", Model: \"deepseek-v4-flash\", Price: &provider.Pricing{Input: 1}}\n\tgot := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"})\n\tif len(got) != 1 || got[0].Name != \"deepseek-flash\" {\n\t\tt.Fatalf(\"got %+v, want one deepseek-flash entry\", got)\n\t}\n\tif !reflect.DeepEqual(got[0].Models, []string{\"deepseek-v4-flash\", \"deepseek-v9-experimental\"}) {\n\t\tt.Errorf(\"Models = %v, want both under the probe entry\", got[0].Models)\n\t}\n}\n\n// TestBuildFamilyEntry covers the three observable behaviors:\n// - The selected models land in the entry's Models field, with Model\n// pointed at the first one so legacy single-model lookups still work.\n// - A preset Default that points to a model the user didn't pick is\n// reset to the first selected model (otherwise resolve-by-default\n// would silently break).\n// - A preset Default that IS in the selection is preserved.\nfunc TestBuildFamilyEntry(t *testing.T) {\n\tt.Run(\"default reset when not in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Kind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-pro\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\"})\n\t\tif got.Model != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Model = %q, want deepseek-v4-flash\", got.Model)\n\t\t}\n\t\tif got.Default != \"deepseek-v4-flash\" {\n\t\t\tt.Errorf(\"Default = %q, want reset to first selected\", got.Default)\n\t\t}\n\t\tif !reflect.DeepEqual(got.Models, []string{\"deepseek-v4-flash\"}) {\n\t\t\tt.Errorf(\"Models = %v\", got.Models)\n\t\t}\n\t\tif got.BaseURL != \"https://api.deepseek.com\" {\n\t\t\tt.Errorf(\"BaseURL lost: %q\", got.BaseURL)\n\t\t}\n\t})\n\n\tt.Run(\"default preserved when in selection\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{\n\t\t\tName: \"deepseek\", Default: \"deepseek-v4-pro\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t}\n\t\tgot := buildFamilyEntry(probe, []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"})\n\t\tif got.Default != \"deepseek-v4-pro\" {\n\t\t\tt.Errorf(\"Default = %q, want preserved\", got.Default)\n\t\t}\n\t})\n\n\tt.Run(\"empty default filled from first selected\", func(t *testing.T) {\n\t\tprobe := config.ProviderEntry{Name: \"x\", BaseURL: \"u\"}\n\t\tgot := buildFamilyEntry(probe, []string{\"alpha\", \"beta\"})\n\t\tif got.Default != \"alpha\" {\n\t\t\tt.Errorf(\"Default = %q, want alpha\", got.Default)\n\t\t}\n\t})\n}\n\n// TestProviderSlug covers the host-derivation rules and the sha1 fallback\n// for unparseable URLs. The exact format isn't load-bearing — what matters\n// is that the slug (a) starts with the kind prefix, (b) is stable across\n// calls with the same URL, and (c) never produces the bare \"custom\" /\n// \"anthropic\" magic names that would collide with the wizard menu items.\nfunc TestProviderSlug(t *testing.T) {\n\tcases := []struct {\n\t\tname, kind, url, want string\n\t}{\n\t\t{\"standard host with port\", \"custom\", \"https://token.sensenova.cn/v1\", \"custom-token-sensenova-cn\"},\n\t\t{\"api subdomain\", \"custom\", \"https://api.openai.com/v1\", \"custom-api-openai-com\"},\n\t\t{\"www stripped\", \"custom\", \"https://www.example.com/v1\", \"custom-example-com\"},\n\t\t{\"port preserved\", \"custom\", \"http://localhost:11434/v1\", \"custom-localhost-11434\"},\n\t\t{\"anthropic kind\", \"anthropic\", \"https://api.anthropic.com\", \"anthropic-api-anthropic-com\"},\n\t}\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := providerSlug(tc.kind, tc.url); got != tc.want {\n\t\t\t\tt.Errorf(\"providerSlug(%q, %q) = %q, want %q\", tc.kind, tc.url, got, tc.want)\n\t\t\t}\n\t\t})\n\t}\n\n\tt.Run(\"stable across calls\", func(t *testing.T) {\n\t\ta := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tb := providerSlug(\"custom\", \"https://token.sensenova.cn/v1\")\n\t\tif a != b {\n\t\t\tt.Errorf(\"not stable: %q vs %q\", a, b)\n\t\t}\n\t\tif a == \"custom\" {\n\t\t\tt.Error(\"slug degenerated to bare magic name — collision risk\")\n\t\t}\n\t})\n\n\tt.Run(\"sha1 fallback for unparseable URL\", func(t *testing.T) {\n\t\tgot := providerSlug(\"custom\", \"://not a url::://\")\n\t\tif !strings.HasPrefix(got, \"custom-\") || got == \"custom\" {\n\t\t\tt.Errorf(\"fallback slug = %q, want custom-\", got)\n\t\t}\n\t\t// sha1 is 40 hex chars; we take 4 bytes (8 hex chars).\n\t\tif len(got) != len(\"custom-\")+8 {\n\t\t\tt.Errorf(\"fallback slug = %q, want 8 hex chars after prefix\", got)\n\t\t}\n\t})\n}\n\n// TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy\n// \"custom\" / \"anthropic\" magic-name entries that previous versions wrote\n// into reasonix.toml. These collide with the wizard's own menu items, so\n// they're dropped from the providers list before grouping — but the caller\n// still gets them back in the dropped slice to surface a warning.\nfunc TestFilterStaleCustomEntries(t *testing.T) {\n\tin := []config.ProviderEntry{\n\t\t{Name: \"deepseek\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"custom\", Kind: \"openai\", BaseURL: \"https://old.example/v1\"}, // stale\n\t\t{Name: \"anthropic\", Kind: \"anthropic\", BaseURL: \"https://old.example/v1/messages\"}, // stale\n\t\t{Name: \"mimo-tp\", Kind: \"openai\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\"},\n\t}\n\tkept, dropped := filterStaleCustomEntries(in)\n\tif len(kept) != 2 {\n\t\tt.Errorf(\"kept = %d entries, want 2: %+v\", len(kept), kept)\n\t}\n\tif len(dropped) != 2 {\n\t\tt.Errorf(\"dropped = %d entries, want 2: %+v\", len(dropped), dropped)\n\t}\n\tfor _, k := range kept {\n\t\tif k.Name == \"custom\" || k.Name == \"anthropic\" {\n\t\t\tt.Errorf(\"magic name leaked through: %q\", k.Name)\n\t\t}\n\t}\n\n\tt.Run(\"non-magic names with kind anthropic are kept\", func(t *testing.T) {\n\t\t// An entry someone deliberately named \"claude\" (kind=anthropic) must\n\t\t// not be touched by the filter — only the bare \"anthropic\" magic name.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"claude\", Kind: \"anthropic\", BaseURL: \"https://api.anthropic.com\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"claude should be kept, got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n\n\tt.Run(\"custom kind anthropic is kept\", func(t *testing.T) {\n\t\t// Name=\"custom\" with kind=anthropic is ambiguous — keep it.\n\t\tin := []config.ProviderEntry{\n\t\t\t{Name: \"custom\", Kind: \"anthropic\", BaseURL: \"https://x\"},\n\t\t}\n\t\tkept, dropped := filterStaleCustomEntries(in)\n\t\tif len(kept) != 1 || len(dropped) != 0 {\n\t\t\tt.Errorf(\"custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d\", len(kept), len(dropped))\n\t\t}\n\t})\n}\n\nfunc TestWithBuiltinFamiliesAddsMissingMiMo(t *testing.T) {\n\t// The user's case: a reasonix.toml that defines only deepseek providers.\n\tcfg := []config.ProviderEntry{\n\t\t{Name: \"deepseek-flash\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t\t{Name: \"deepseek-pro\", Kind: \"openai\", BaseURL: \"https://api.deepseek.com\"},\n\t}\n\torder, _, info := groupByFamily(withBuiltinFamilies(cfg))\n\tseen := map[string]bool{}\n\tfor _, k := range order {\n\t\tseen[info[k].name] = true\n\t}\n\tif !seen[\"DeepSeek\"] || !seen[\"MiMo (Xiaomi)\"] {\n\t\tt.Fatalf(\"wizard families = %v, want both DeepSeek and MiMo\", order)\n\t}\n\t// A user's customized deepseek must not be duplicated.\n\tif n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), \"deepseek\")); n != 2 {\n\t\tt.Fatalf(\"deepseek members = %d, want the user's 2 (no injected duplicate)\", n)\n\t}\n}\n\nfunc groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {\n\t_, members, _ := groupByFamily(ps)\n\treturn members[key]\n}\n"}}, "deploy/docker-compose.yml": {"57213b093187ad62782de21847d782b2a02987c2": {"t": 1785746989.7523656, "n": 994, "h": "8d53faa4d4d46c89", "c": "# Reasonix-Hermes — single-node deploy for $5 VPS.\n# Start: docker-compose up -d\n# Stop: docker-compose down\n\nservices:\n mcpbridge:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-mcpbridge\", \"--http\", \"--port\", \"9090\"]\n ports:\n - \"9090:9090\"\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n restart: unless-stopped\n\n memoryserver:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-memoryserver\", \"--http\", \"--port\", \"9091\"]\n ports:\n - \"9091:9091\"\n volumes:\n - memory-data:/data\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n restart: unless-stopped\n\n bot:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-bot\", \"--model\", \"deepseek-flash\"]\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}\n restart: unless-stopped\n profiles:\n - bot\n\nvolumes:\n memory-data:\n"}, "bafeaf2814d46c3dce68172807dfcab04f20fbf3": {"t": 1785750389.3465683, "n": 994, "h": "8d53faa4d4d46c89", "c": "# Reasonix-Hermes — single-node deploy for $5 VPS.\n# Start: docker-compose up -d\n# Stop: docker-compose down\n\nservices:\n mcpbridge:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-mcpbridge\", \"--http\", \"--port\", \"9090\"]\n ports:\n - \"9090:9090\"\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n restart: unless-stopped\n\n memoryserver:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-memoryserver\", \"--http\", \"--port\", \"9091\"]\n ports:\n - \"9091:9091\"\n volumes:\n - memory-data:/data\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n restart: unless-stopped\n\n bot:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-bot\", \"--model\", \"deepseek-flash\"]\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}\n restart: unless-stopped\n profiles:\n - bot\n\nvolumes:\n memory-data:\n"}, "5a33ed8d59e859dd57363070a755d816c30384ba": {"t": 1785750390.411641, "n": 1010, "h": "df2d13febcc30cab", "c": "# Reasonix-Hermes — single-node deploy for $5 VPS.\n# Start: docker-compose up -d\n# Stop: docker-compose down\n\nversion: \"3.8\"\n\nservices:\n mcpbridge:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-mcpbridge\", \"--http\", \"--port\", \"9090\"]\n ports:\n - \"9090:9090\"\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n restart: unless-stopped\n\n memoryserver:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-memoryserver\", \"--http\", \"--port\", \"9091\"]\n ports:\n - \"9091:9091\"\n volumes:\n - memory-data:/data\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n restart: unless-stopped\n\n bot:\n image: ghcr.io/aliatx2017/reasonix-hermes:latest\n command: [\"/app/reasonix-bot\", \"--model\", \"deepseek-flash\"]\n environment:\n - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}\n - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}\n restart: unless-stopped\n profiles:\n - bot\n\nvolumes:\n memory-data:\n"}}}}